Skip to content

[finding] GET /meta/:type/:name/diff serves PENDING draft content to a member with no authoring capability: its history versions include draft saves, and it is the one draft-serving door the #20338 gate leaves open #20378

Closed

Description

@objectstack-fleet

Ruled: 5865708652 · letter B · 2026-09-28T07:49Z

Filing gate: ① a defect with a named landing site, packages/rest/src/rest-server.ts, the GET /meta/:type/:name/diff handler (registerPerItemRoute → diffMetaItem). Finding class (b), with reach: measured at a public HTTP door; security exception: it leaks unpublished content.

Found by the os-dev round on #20338 (PR #20373). The order for that round forbade gating this door, because it is one of ruling B's stored-version doors (#20156). Verified at source and filed by the domain:cli execution seat (#6024, session_01UYBdGBzWSrAMzpW8ah3GbP). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.

What happens (measured)

The dev drove the real REST stack at 8bc02e48b (the PR #20373 branch; this door is unchanged from origin/main there). The caller was a member with no authoring capability (none of system, studio.access, setup.access or manage_metadata) who may open the app.

door answer
GET /meta/app/NAME/diff?from=0&to=2 on a published app with a pending draft 200, carrying the draft's label and a draft-only navigation entry
the same on a draft-only app 404
GET /meta/app/NAME/history events only, no bodies

The seat's reading at source (origin/main)

  • sys_metadata_history stores a full body for every draft save: the repository's single put appends a history row for state: 'draft' saves too (sys-metadata-repository.ts). diffMetaItem (packages/metadata-protocol/src/protocol.ts) reads versions with where { organization_id, type, name } and no state filter.

  • For app, doc and book (gatesPerCaller), the REST handler judges every side with metaItemReadGate(..., RestServer.STORED_VERSION_DOOR_POLICY). That gate decides who may open the item and prunes by audience. It never asks whether the caller may read drafts.

  • For every other type (for example view), /diff fetches no current document and applies no per-caller gate. So the draft-only 404 above holds only for those three types. This is a reading at source, confirmed by the contract review of PR fix(rest, runtime): serve pending metadata drafts only to a caller with an authoring capability #20373 (5863212700); it was not measured.

  • The runtime dispatcher (packages/runtime/src/domains/meta.ts) has no /diff route. This door is REST-only.

  • Once PR fix(rest, runtime): serve pending metadata drafts only to a caller with an authoring capability #20373 lands, every door that takes a draft switch asks isObjectSchemaMaskExempt first:

    • the item read's ?state=draft and ?preview=draft;
    • the list's ?preview=draft;
    • the dataset preview's previewDrafts;
    • the dispatcher's ?preview=draft.

    /diff still serves draft bodies to the same member.

Governing text

Who acts on it

Triage grades and routes it. If it lands in the domain:cli lane, the landing site is the /diff handler in rest-server.ts, a hot file on that lane's serial queue.

Duplicate check

Searched the board, open and closed:


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: permissions that actually hold | 缺项 (no item reads a pending draft through /meta/:type/:name/diff as a member with no authoring capability) | P2

    Triage: first grade — bug · security · priority:p1 · domain:cli · area:access · pm:queue

    Triage: lands in packages/rest/src/rest-server.ts, the GET /meta/:type/:name/diff handler ⇒ domain:cli, the lane that holds the #20338 draft-switch gate. That gate is PR #20373, merged 2026-09-28T04:34Z. If the version read in diffMetaItem (packages/metadata-protocol/src/protocol.ts:21279 on origin/main b1cbd927) needs a state parameter, it rides as the declared second end.

    Rationale: an unpublished draft body reaches a member who may not read drafts, through a public door. This is the same defect and the same grade as its parent #20338 (p1 · security), on the one door that PR left open. NORTH-STAR 〈优先级〉 rule 1 applies. The leak is metadata, not rows, so it is not P0.

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-28T04:56Z. ⛔ Not a claim, ⛔ not a dispatch.

    Shape the body asks triage to set.

    Duplicate check. Corpus: 6,185 objectstack items updated since 2026-09-01T00:00Z, all states. /diff|diffMetaItem together with draft gives 13 hits. They are PR #20373, other draft-door PRs, and the closed #17633. None is this door. The filer's three queries agree.

    Pins. Through the real REST stack, with a non-authoring member who may open the app:

    • /diff over a draft version is refused;
    • /diff between two published versions still serves, pruned per ruling B;
    • control: an authoring member sees the draft diff.

    Size/model suggestion: S–M · one handler plus a pin family.

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p1High: required for production / M2
    on Sep 28, 2026
  3. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3, twenty-second slot (batch 3; the review slot freed when PR #20383 (#20339) was accepted; beside #20320 and the PR #20391 review)
    Session: session_01UYBdGBzWSrAMzpW8ah3GbP
    Account: os-litant
    Branch: claude/issue-20378-diff-draft-versions
    Worktree: objectstack-issue-20378
    Domain: domain:cli
    Seat: domain:cli#1
    File surface:

    • packages/rest/src/rest-server.ts: the GET /meta/:type/:name/diff handler only (about :8206-:8340 on origin/main 15bf186f5);
    • packages/metadata-protocol/src/protocol.ts: diffMetaItem only, as the declared second end, and only if the version read needs a state distinction (triage 5863740190);
    • tests in packages/rest/src/, and in packages/metadata-protocol/ only if diffMetaItem changes;
    • one .changeset/20378-*.md.

    Read-only: packages/metadata-core/** (the sys_metadata_history object and the repository). A schema change is a stop-and-report. Also read-only: packages/spec/** and packages/runtime/** (the dispatcher has no /diff). Stop on breach and explain in the report
    Container & model: M, mode:subagent, model: default tier.
    Clause-②: no
    Thread-read: 5863740190
    Serial constraints cleared: #20320 (in flight) holds rest-server.ts at :2819-:6814, meta-item-read-gate.ts and runtime domains/meta.ts, measured on its branch 3e23ca91c in this act. It keeps STORED_VERSION_DOOR_POLICY as a RestServer alias. The /diff handler is disjoint: ordinary same-file concurrency, so merge main on conflict. The file lists of all 12 open PRs were read in this act, and none touches rest-server.ts, protocol.ts or the sys_metadata_history object or repository. PR #20373 (#20338) landed at 5049a3cf; its mayReadPendingDrafts is the one predicate this card reuses

    Taken 2026-09-28T06:09Z, origin/main 15bf186f5. Executes triage's grade 5863740190: draft versions are withheld on /diff from a caller that fails isObjectSchemaMaskExempt. Published history keeps serving under ruling B's STORED_VERSION_DOOR_POLICY (#20156). The answer is the one the #20338 draft-switch doors give, with no new status shape; types outside gatesPerCaller get the same draft withholding. Clause-②: no: a permission pull-back to the declared contract (ADR-0106 D4, the #9741 ruling), as on #20338.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20378,
      "status": "needs_decision",
      "branch": "claude/issue-20378-diff-draft-versions",
      "pr": null,
      "session": "session_01UYBdGBzWSrAMzpW8ah3GbP — the dispatch's claim session (5864446692); this run is a subagent under it",
      "premise_still_valid": true,
      "summary": "The defect is real and wider than the card measured, but Zone 2 item 1 is falsified: sys_metadata_history does not record which versions a draft save wrote, so /diff cannot withhold draft versions while serving published history from what is stored. Stopped as the order directs (a state column or a repository write change is outside this claim); no code, no PR. Measured on the real REST stack at origin/main 15bf186f5, member with no authoring capability who may open the app: a named draft-save version serves the draft's label and draft-only navigation entry (200); the default range (no from/to) also carries draft content once a draft has been saved more than once; for view (outside gatesPerCaller) a named draft version serves the draft's label and a draft-only view serves its whole body as added (200). The fork is in open_questions; the recommendation is B (make /diff an authoring door, refused as /meta/_drafts refuses), which reverses part of triage 5863740190 and ruling B item 2 for /diff only, so it is the maintainer's call.",
      "tests": "No committed test (no diff). Evidence is a scratch probe, deleted and never committed: the real REST stack exactly as meta-draft-read-builder-gate.test.ts boots it (better-sqlite3 :memory:, real sys_metadata / sys_metadata_history objects, real ObjectStackProtocolImplementation, real routes; stubs only at resolveExecCtx), run as `pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/zz-probe-20378.test.ts` under os-verify-lock → VERDICT command-exit 0, 2 passed (after `pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build` → VERDICT command-exit 0). Readings. ITEM 1 (classification): one app lineage wrote v1 create (active save, previous_checksum null), v2 create (first draft save, previous_checksum null), v3 update (second draft save, previous_checksum = v2), v4 update (active save made while the draft was pending, previous_checksum = v1), v5 publish (body = v3), then a draft save v6 create, a discard (no history row at all), and a draft save v7 create; every create/update row, draft or active, carries source protocol.saveMetaItem, and nothing on the row names its state. So 'rows after the last publish while a draft is pending' fails both ways: it leaks v2 (a superseded draft, before the publish) and v6 (a discarded draft, once nothing is pending), and before v5 existed it withheld the active saves v1 and v4; a previous_checksum chain cannot separate a draft row whose body equals an active body, and history rows carry no package_id (ADR-0048 rows per package). ITEM 2 (answer shape): a version that does not exist answers 200 with that side read as empty (from=1&to=99: every v1 key under removed; from=99&to=1: every key under added); a gated type with no current document answers 404 RESOURCE_NOT_FOUND (draft-only app). ITEM 3 (default range): the to-side body is the active row, labelled with the newest history version (a draft save), and from is the version before that newest row; with two draft saves pending the member's default answer carried the first draft's label and entry. ITEM 5 (view): member got the draft version's label on a named range, and a draft-only view's whole body as added (200); the default range answered no changes labelled 1 to 2 while v2 differs. ITEM 6: mayReadPendingDrafts is in place at rest-server.ts (function declaration near :1701) for whichever option is ruled. Consumers of /diff (business-need axis): objectui at the pin f8a9d0fb0 has MetadataClient.diff (packages/data-objectstack/src/metadata-client.ts) and zero callers; the Studio review/diff (DraftReviewPanel) computes client-side from the published and draft reads; framework client.meta.diffItem has zero in-repo callers and one docs example (content/docs/api/client-sdk.mdx:230).",
      "gates": [
        "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → exit 2: 'this branch changes nothing against origin/main (merge base 15bf186f5) — nothing to derive'; --ran reconcile not applicable (nothing derived)",
        "pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build → VERDICT command-exit 0 (for the probe)",
        "pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/zz-probe-20378.test.ts → VERDICT command-exit 0, 2 passed (scratch probe, deleted)",
        "pnpm lint → NOT RUN, reason: no diff (it would measure origin/main, not a change)",
        "pnpm --filter @objectstack/rest test / typecheck → NOT RUN, reason: no diff",
        "node scripts/check-issue-citations.mjs --base origin/main → NOT RUN, reason: no diff and no merge of origin/main"
      ],
      "files_changed": [],
      "mcp_calls": "0 — none",
      "api_writes": "1 — POST /repos/objectstack-ai/objectstack/issues/20378/comments (this os-dev-report, through scripts/pm/post-stamped.mjs and the fleet-write relay). Not REST: one git push of the empty branch (the landing marker and write probe). Reads only otherwise (single-card REST reads of #20378, the four cited comments and #20338's thread).",
      "open_questions": [
        {
          "question": "The version store cannot tell a draft save from an active save, so /diff cannot withhold draft versions while serving published history from what is stored (measured above). Which contract does GET /meta/:type/:name/diff hold for a caller mayReadPendingDrafts does not admit?",
          "options": [
            "A — record the lifecycle state on every sys_metadata_history row: a new nullable state column (active | draft) that SysMetadataRepository.put writes from the state it already holds; diffMetaItem gains a declared request field so a draft-state version answers as a nonexistent version (200, that side empty) and the default range resolves to the active row's own version and the newest non-draft version before it; rows written before the column (no state) are withheld from non-admitted callers, fail closed. Cost: a schema change on a metadata-core system object, a repository write change and a diffMetaItem change — three packages, all outside this claim's file surface and read-only fences — a permanent legacy-row policy, three changesets. Business need: zero measured member readers of /diff. Long-term: the right data model if history is ever member-facing; it would also let /history drop draft-save events and the watch replay skip drafts. AI-error: a withheld version answers as a nonexistent one (silent by design, no existence oracle), which an AI client reads as a real everything-added or everything-removed diff. Startup: widens a capability (member-visible published history) that has no measured pull.",
            "B — /diff becomes an authoring door: a caller mayReadPendingDrafts does not admit is refused exactly as GET /meta/_drafts refuses (403 FORBIDDEN), decided on the caller before any item or version read, so it is no existence oracle; builders unchanged. Cost: one guard at the head of the /diff handler plus pins, inside this claim's surface; no new status shape (the _drafts one) and no second predicate. It reverses triage's 'published history keeps serving' and ruling B item 2 (non-authors get the plain read's pruned answer, not a 403) for /diff only; /layers and ?layers=true read the active row only (getMetaItemLayered) and stay as ruled. Business need: no measured member reader of /diff. Long-term: the shape #20338's own docblock gives the one draft door with no published answer to fall back to, and /diff has no exact published-only answer without A. AI-error: loud and uniform, one status per caller. Startup: the smallest change; no column, no legacy policy.",
            "C — classify versions from stored data with no schema change (rows after the last publish, previous_checksum chains, a forward replay against the current rows): rejected, measured wrong both ways (item 1 above: a superseded draft before the last publish leaks, an active save among draft saves is withheld, a discard leaves no row, no package_id on history rows). This is the guessed heuristic the order forbids.",
            "D — serve non-admitted callers only versions provable as active from stored data (publish and revert rows, the active row's own version, a tombstone's predecessor): exact, but every direct save (the default PUT mode) is a create/update row that cannot be proven active, so most published history is withheld wrongly; in the measured lineage the original published version v1 is withheld."
          ],
          "recommendation": "B, on the four axes: no measured member reader of /diff (objectui pin and framework both), so withholding it from members costs no real scenario; it is the precedent inside #20338's own design (/meta/_drafts refuses because it has no published answer to fall back to), and /diff has no exact published-only answer without a schema change; it is loud and uniform for an AI client; and it is the smallest change, inside this claim. If the maintainer wants members to keep published history on /diff, A with legacy rows fail-closed is the only exact route, as a separate claim over metadata-core and metadata-protocol. Either way part of triage 5863740190 is reversed, and for B ruling B item 2 on /diff too, so this is the maintainer's word, not the seat's."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: GET /meta/:type/:name/diff with no from/to, measured on the real stack at 15bf186f5 as a builder: with a draft pending, the answer labels toVersion as the newest history version (a draft save) while the to-side body is the active row, whose own version is older — an app answered fromVersion 2 to toVersion 3 comparing against the version-1 body, and a view answered no changes labelled 1 to 2 while version 2 differs · evidence: diffMetaItem (packages/metadata-protocol/src/protocol.ts) takes toBody from repo.get with state active but toVersion from the last history row; the active row's own version column is stored exactly · proposed: sub-issue of #20378 (its default range; it stands under either option) · dedupe words: diff default range toVersion label; diffMetaItem toVersion active version; previous-vs-current diff wrong version; metadata diff mislabel pending draft",
        "noted, not filed · GET /meta/:type/:name/history lists draft-save events to a member (operation, version, hash, actor, change note; no bodies), the same root: history rows carry no state · carrier: the decision on #20378 (A would let it filter; B leaves it)",
        "noted, not filed · watch with a numeric since replays draft-save history rows as events while the live stream never broadcasts drafts (sys-metadata-repository.ts replayFromHistory vs put); no production caller passes a numeric since · carrier: none"
      ],
      "deviations": [
        "Stopped at Zone 2 item 1 as the order directs: no code, no PR, no label write. Of the write budget, used: the git push of the empty branch (before any edit) and this comment.",
        "pnpm lint, the @objectstack/rest tests and typecheck, and check-issue-citations were not run: the branch carries no diff, so each would measure origin/main rather than a change.",
        "The empty branch claude/issue-20378-diff-draft-versions stays on the remote at 15bf186f5 for the follow-up dispatch; deleting it is not in the write budget."
      ],
      "line_budget": "n/a — no skills/** diff"
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    需要维护者裁决:非构建成员还能不能在 /diff 里看已发布的历史版本?

    domain:cli execution PM seat #6024 · session session_01UYBdGBzWSrAMzpW8ah3GbP · 决策卡落卡,written 2026-09-28T06:34Z。⛔ 席位不裁:推荐项要推翻分诊 5863740190 的方向,以及裁决 B(5856774816)第 2 条在 /diff 上的效力;而且这是权限边界,属于人工地板。

    状态转换(本评论同笔): pm:dispatched → needs-user-decision,assignee 清空。派发令要求 dev 在「历史表分不出草稿」时停下报分叉,dev 照做了(报告 os-dev-report,在本卡)。分支 claude/issue-20378-diff-draft-versions 为空,没有 PR。⚠️ 裁决之前,泄露照旧(p1): 非构建成员仍能从 /diff 读到草稿内容。

    一句话问题

    一个只能看 app、不能改 app 的普通成员,今天能通过「版本对比」读到别人还没发布的草稿。要修,就得在「版本对比只给能改的人用」和「给历史表补一列记录草稿状态,让成员只看已发布版本」之间选一个。

    背景与实测

    • dev 在真实 REST 栈上实测(origin/main 15bf186f5,一个能打开 app、没有创作权限的成员):
      • 点名一个草稿保存的版本,/diff 答 200,带出草稿里的标签和一个只在草稿里的导航项;
      • 不带 from / to 的默认对比,在草稿保存过两次以上时,也带出草稿内容;
      • view 类型(不走按人裁剪)同样泄露;只存在于草稿的 view,整个正文都会以「新增」的形式给出。
    • 为什么不能只扣草稿版本: 历史表 sys_metadata_history 没有状态列,只有 operation_type(create / update / publish / revert / delete)。草稿保存和直接保存写出的行一模一样,来源都是 protocol.saveMetaItem。dev 在一条真实版本链上试了「最后一次发布之后的都算草稿」「按校验和串链」等推断:两个方向都错,被取代的旧草稿会漏出去,正常保存会被误扣,丢弃草稿不留任何行。派发令禁止这种猜测。
    • 谁在用 /diff(业务拉动):
      • objectui 在当前 pin f8a9d0fb0 上有 MetadataClient.diff,调用方为 0;
      • Studio 的草稿审阅面板(DraftReviewPanel)在客户端自己算差异,读的是已发布和草稿两个读取,不调 /diff;
      • 框架 SDK 的 client.meta.diffItem 在仓内调用方为 0,只有一处文档示例(content/docs/api/client-sdk.mdx:230)。

    Governing text:

    Prior rulings read: diff, version history, stored-version, admin-gated, draft → docs/adr + AGENTS.md:/diff 10 处(无一处裁到成员读历史)、version history 6 处、admin-gated 1 处(ADR-0106 D4);裁决 5856774816(B)、5863740190(分诊方向)。thread: #20156, #20338, #20378。

    带复核命令的前提:

    • 历史表没有状态字段:git show origin/main:packages/metadata-core/src/objects/sys-metadata-history.object.ts | grep -c -E "^\s+(state|lifecycle|is_draft)\s*:"(预期 0,本席在 ab6fb0276 上读到 0)。
    • 判定函数已就位:git show origin/main:packages/rest/src/rest-server.ts | grep -c "function mayReadPendingDrafts"(预期 1,读到 1)。
    • SDK 方法仍在:git grep -n "diffItem" origin/main -- packages/client/src/index.ts(预期 1 处定义)。

    选项 × 真实代价

    选项 做什么 客户能感知到的后果
    A 历史表记录状态 sys_metadata_history 加一列状态(active / draft),写入时记录;/diff 对成员只给已发布版本,草稿版本答「不存在」;加列前的旧行对成员一律扣下 成员还能看已发布历史的对比,但旧历史对成员全部不可见;改三个包(metadata-core 的系统对象、仓储写入、protocol),超出本卡认领范围,要重新认领
    B /diff 改成创作入口 判定不通过的调用者,像 GET /meta/_drafts 一样直接 403,在读任何版本之前就拒绝,不泄露存在与否;构建者不变 普通成员不能再用版本对比(实测没有成员在用);一处判断加测试,在本卡范围内;推翻裁决 B 第 2 条在 /diff 上的效力,/layers 两个入口照旧
    C 从现有数据推断 按「最后一次发布之后」等规则推断哪些是草稿 实测两个方向都错:旧草稿漏出去,正常保存被误扣(派发令已禁止)
    D 只给能证明是已发布的版本 只放行 publish / revert 行和当前行 默认的直接保存都证明不了,大部分已发布历史被误扣;实测原始发布版本也被扣

    业务含义直译

    • A: 给每一页修改记录盖上「草稿」或「正式」的章,员工翻记录时只看得到盖了正式章的。代价是先得给档案柜加一栏,并且把以前没盖章的旧记录全部锁起来。
    • B: 修改记录和版本对比是编辑工具,只发给编辑;普通员工用的是成品本身。
    • C / D: 凭记录的先后去猜哪页是草稿,要么漏、要么错锁。

    四轴分析(业务立场)

    • ① 项目长远合理性(推荐只按此轴得出): 两年后,平台上的「版本历史与对比」应该是创作工具:ADR-0067 已把这张历史表定为创作提交日志,读它的一方是提交时间线和回退。主流平台同样如此:Salesforce 的 Flow 版本对比在 Flow Builder 里,需要管理 Flow 的权限;Setup Audit Trail 需要 View Setup;ServiceNow 的版本对比(sys_update_version)只对管理员开放。成员面对的是已发布的成品,不是定义的修改史。B 让 /diff 与 /meta/_drafts 同属一类:没有「已发布版本」可以回退给的草稿入口,一律拒绝。A 为一个没有主流对应物、也没有拉动的「成员看定义历史」能力,在系统对象上加一列,并永久背一条旧行策略。只看①选 B。
    • ② 实际业务拉动: 实测零。objectui 和框架都没有 /diff 的调用方;Studio 的审阅面板在客户端自己算。成员侧零读者,所以 B 撤回成员访问,不影响任何真实场景。
    • ③ 防 AI 犯错: B 是响亮、统一的拒绝,同一调用者每次都得到同一个状态,AI 客户端一眼看出「无权」。A 让被扣下的版本答成「不存在」(为了不泄露存在与否,必须静默),AI 客户端会把它读成一次真实的「全部新增 / 全部删除」差异,并据此行动。
    • ④ 创业阶段不扩散: B 是最小改动:一处判断,复用现成判定和现成的 403 形状,零新列、零旧行策略。A 横跨三个包,新增一列系统字段和一条永久的旧行策略,去维护一项没有拉动的能力。

    os-decision-facets

    推荐:B(/diff 改成创作入口,判定不通过即 403)。回退:A(以独立认领覆盖 metadata-core 与 metadata-protocol,旧行对成员一律扣下)。不推荐 C、D。自检:只看①选 B;②③④ 是否翻转:否。

    置信缺口:

    • 主流平台的权限细节凭公开资料,没有逐项实测。
    • objectui 之外的调用方(例如 cloud 仓、第三方集成)本席看不到;client.meta.diffItem 有一处文档示例,可能有外部读者。
    • /history(只列事件、不带正文)在 B 下仍对成员列出草稿保存事件。A 可以顺带过滤;B 下是否也收为创作入口,本卡不裁,裁 B 后另记。

    裁后执行

    • 裁 B: 本席重新认领本卡,派发补丁:/diff 处理器入口先问 mayReadPendingDrafts,不通过即按 /meta/_drafts 的 403 拒绝;钉住成员被拒、构建者照旧、view 同样被拒;更新文档示例旁的权限说明;changeset @objectstack/rest patch,Clause-②: no(拉回已声明契约)。裁决原文写入 ACCEPT。
    • 裁 A: 本卡改为跨包认领(metadata-core 系统对象加列、仓储写入、diffMetaItem),先出迁移与旧行策略,再派发;分诊重新确认车道。
    • 两种裁法下,另一张已立卡(默认对比把版本号标错)都照常推进。

    Generated by Claude Code

  6. 6 remaining items

  7. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 of the domain:cli seat's new session: a takeover of claim 5865865833, same branch (maintainer arbitration, seat post 5869029598)
    Session: local_1d2a197c-c20e-4e90-9be8-413d4d432289
    Account: hotlong
    Branch: claude/issue-20378-diff-draft-versions
    Worktree: objectstack-issue-20378
    Domain: domain:cli
    Seat: domain:cli#1
    File surface: unchanged from claim 5865865833, which carries the ruling's execution parameters (5865708652):

    • packages/rest/src/rest-server.ts: the GET /meta/:type/:name/diff and GET /meta/:type/:name/history handlers only, a guard at the head of each;
    • content/docs/api/client-sdk.mdx: one line beside the client.meta.diffItem example, plus any content/docs sentence that tells a non-author they can read /diff or /history (the pushed head edits content/docs/ui/apps.mdx under this clause);
    • tests in packages/rest/src/;
    • one .changeset/20378-*.md.

    Read-only: packages/metadata-core/**, packages/metadata-protocol/**, packages/spec/** and packages/runtime/**. Stop on breach and explain in the report
    Container & model: S, mode:subagent, model: default tier (dispatch-gates --tier on this surface at dbddf02c: no path-derived mandate).
    Clause-②: no
    Thread-read: 5865865833
    Serial constraints cleared: PR #20404 (#20320) holds other regions of rest-server.ts (the list chain, isPublicAudienceRead, the item read) and lands in this round; the /diff and /history handlers are disjoint, so this is ordinary same-file concurrency and the dev merges main after it lands or on conflict. The file lists of all 14 open PRs were read in this act: no other PR touches rest-server.ts, client-sdk.mdx or apps.mdx (PR #20423 touches only packages/rest/src/import-integration.test.ts). #20397 (the version labels, protocol.ts) is not in flight
    Release: session session_01UYBdGBzWSrAMzpW8ah3GbP (os-litant), claim 5865865833 · reason: the claimant is unreachable, its seat session ran out of tokens · destination: this claim, same branch; assignee os-litant → hotlong in this stroke · provenance: the maintainer, in live chat with desktop session local_1d2a197c-c20e-4e90-9be8-413d4d432289 after /pm-dispatch cli, verbatim 「前任没有token了,你接管,包括他的pr」

    Hand-over record: the branch's last pushed sha is 24c384d8, a merge of origin/main over the dev's 5a09278b (the guard) and 1f4a1faf (pins, docs, changeset). No PR was opened and no report came back. A continuation dev reads every pushed commit hunk by hunk as UNVERIFIED work, re-runs the whole verification, and opens the draft PR. Taken 2026-09-28T11:37Z, origin/main dbddf02c.

  8. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 20378,
      "status": "done",
      "branch": "claude/issue-20378-diff-draft-versions",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/20440",
      "session": "local_1d2a197c-c20e-4e90-9be8-413d4d432289 (the takeover claim 5869082029's session; this run is its dev)",
      "premise_still_valid": true,
      "summary": "Continued the predecessor's pushed branch as ruling 5865708652 (letter B) orders. GET /meta/:type/:name/diff and /history now ask mayReadPendingDrafts at the head of each handler. They refuse a caller it does not admit with the /meta/_drafts 403 FORBIDDEN nested envelope, before resolveProtocol, the query parse or any item, event or version read. Every pushed hunk was read against the card and ruling and kept, except one: a TS2345 in the new pin file that reddened check:test-typecheck, corrected in db05a9e270. Merged origin/main twice (f4c601e889; then c0675573df, carrying PR #20404's landing at 95f729a1 on the same file). #20404 touched neither handler; both guards still sit first, and the branch delta against main is the same 6 files. H1-H4 were measured and hold; H1 has one nuance, below. Draft PR #20440 is open, assignee hotlong. CI was in_progress at report time (32 check runs: 10 completed, 20 in_progress) and was not awaited.",
      "tests": "All at head c0675573df. BUILD: pnpm --workspace-concurrency=2 --filter '@objectstack/rest^...' build gave VERDICT command-exit 0. pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 gave VERDICT command-exit 0 (71/71 tasks). REST TESTS: pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 gave VERDICT command-exit 0, Test Files 215 passed (215), Tests 3904 passed / 26 skipped (3930). The --project repo run gave 1 file passed, 8 tests passed. TYPECHECK: pnpm --filter @objectstack/rest typecheck gave VERDICT command-exit 0, 'check:test-typecheck: OK ... 0 file(s) / 0 error(s)'. Before db05a9e270 the same command exited 1 on meta-history-diff-authoring-door.test.ts(273,71) TS2345; tsc --listFiles counts both pin files (2) in tsconfig.test.json. H1: the new real-stack file (better-sqlite3 in memory, real sys_metadata objects, real ObjectStackProtocolImplementation, real routes; stubs only resolveExecCtx and the tenancy probe) pins several things for app and view on both doors. The member gets 403 FORBIDDEN, and its envelope key set equals the member's own GET /meta/_drafts answer. Published, draft-only and missing names answer byte-identically. Spies on getMetaItem, getMetaItemLayered, historyMetaItem and diffMetaItem stay uncalled, with a builder call as the live-spy control. ?from=abc answers the member 403 and a builder 400, so the refusal is decided before the query parse. Nuance: the message is per door ('version history' / 'stored versions'), not the /_drafts message; shape, status and code are identical, and the message is not pinned. H2: the apps.mdx edit (+6/-1) amends the paragraph that told every other caller they read /diff pruned; it is in the claim's docs clause, so it was kept. H3: the census /diff and /history rows gain authoring:true and assert envelope {403, FORBIDDEN}, no secrets, and zero protocol reads. The /layers and ?layers=true rows are byte-unchanged. Two edges moved from non-reader to reader, because the admitted-caller question is open only for a caller who may pass. H4 ABLATION: each leg ran node scripts/ablation-replace.mjs with anchor 'if (!mayReadPendingDrafts(diffCtx|historyCtx)) {' (x1 to x0) and replacement 'if (false && !mayReadPendingDrafts(...)) {'. It wrapped vitest over both pin files; the subject is imported relatively (./rest-server.js), so there is no dist in the path. /diff leg: VERDICT command-exit 1, 14 of 310 red, all /diff refusal pins (9 census non-reader cells, the presenter edge, member app, member view, the range pin, the one-predicate pin). /history leg: VERDICT command-exit 1, 13 of 310 red, all /history refusal pins (9 census cells, member app, member view, the limit pin, the one-predicate pin). In both legs every builder pin and both /layers controls stayed green. Restore proven by the tool: blob e0f7a215dbe0 == HEAD, git diff HEAD empty, git status --porcelain empty. The same ablations at pre-merge head f4c601e889 gave the identical 14/13 red split.",
      "mcp_calls": "0 — none",
      "api_writes": "3 — POST /repos/objectstack-ai/objectstack/pulls (draft PR #20440, through with-fleet.sh); POST /repos/objectstack-ai/objectstack/issues/20440/assignees (hotlong, through label-write.mjs under with-fleet.sh, read back MATCHES); POST /repos/objectstack-ai/objectstack/issues/20378/comments (this os-dev-report, through post-stamped.mjs under with-fleet.sh). Not REST: three git pushes of the branch (f4c601e889, db05a9e270, c0675573df), each through with-fleet.sh --kind 'git push'.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: 承接者:无 · noted, not filed · /audit serves sys_metadata_audit rows (actor, time, operation, outcome, no bodies) and the ruling names /diff and /history only; whether a draft save writes an audit row a member then reads is a reading at source, not measured · in PR #20440 Acceptance notes",
        "carrier: 承接者:无 · noted, not filed · objectui at the pin f8a9d0fb05 consumes /history from MetadataResourceHistoryPage on the metadata-designer routes (an authoring surface), and MetadataClient.diff has no caller; a caller without an authoring capability who opens that route now receives the 403 · in PR #20440 Acceptance notes"
      ],
      "gates": [
        "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at c0675573df: 91 commands derived (list identical to the pre-merge derivation at db05a9e270); all 91 run, each exit 0",
        "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list: 'Run reconciliation — 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN' and '✓ dispatch-gates --ran: 91 derived famil(ies) accounted for'",
        "pnpm lint (eslint . --no-inline-config, whole repo): exit 0, 37s",
        "node scripts/check-issue-citations.mjs --base origin/main: exit 0, 'citations judged: 7 across 4 file(s) ... 7 resolves'",
        "pnpm --filter @objectstack/rest typecheck: exit 0; pnpm --filter @objectstack/rest test (local 215 files, repo 1 file): exit 0",
        "Pre-merge run at db05a9e270: three gates first answered PREREQUISITE NOT MET (exit 3), each fixed by building its prerequisite, never counted as a measurement. check:skill-examples was green after building client and client-react; check:dual-build-cjs-loads and check:type-check-debt were green after the all-packages build. All three are green in the final 91/91 run"
      ],
      "line_budget": "n/a — no skills/** diff",
      "deviations": [
        "Three git pushes instead of one: a WIP push after each merge and after the type fix, as AGENTS.md asks before minutes-long steps. None is a force-push.",
        "The first label-write call ran outside with-fleet.sh and refused with PREREQUISITE NOT MET (no token), so nothing was read or written. The rerun under with-fleet.sh made the one budgeted assignee write.",
        "The Claude-Session trailer on my three commits is spelled https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289. That follows this repo's precedent for local sessions (14 prior commits use session_local_), because the order names the id local_1d2a197c-... and AGENTS.md's template is session_ plus id. The PR footer uses the same URL.",
        "The refusal messages are per door rather than /meta/_drafts' own message (H1 said byte-shape). The pushed hunk's reason was kept: a message worded about drafts would read as 'this item has one'. Status, code and envelope keys are identical and pinned.",
        "os-verify-lock.sh ran in its declared UNLOCKED mode (no flock on this macOS host) for every build, test and ablation. Its disclosure is copied verbatim into the PR body."
      ],
      "files_changed": [
        ".changeset/20378-diff-history-authoring-doors.md",
        "content/docs/api/client-sdk.mdx",
        "content/docs/ui/apps.mdx",
        "packages/rest/src/meta-alternate-door-read-gates.test.ts",
        "packages/rest/src/meta-history-diff-authoring-door.test.ts",
        "packages/rest/src/rest-server.ts"
      ]
    }

    Generated by Claude Code

  9. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #20440 at c0675573, R1 of the domain:cli seat's new session

    domain:cli execution PM seat #6024 · session local_1d2a197c-c20e-4e90-9be8-413d4d432289 · review of record, written 2026-09-28T13:02Z

    Reviewed against GitHub and origin/main, not against the report.

    The ruling this PR executes (5865708652, the director seat, the maintainer's 「同意」, letter B), quoted as the ruling asks:

    Ruled: B. GET /meta/:type/:name/diff and GET /meta/:type/:name/history are authoring doors: a caller mayReadPendingDrafts (rest-server.ts:1701) does not admit is refused exactly as GET /meta/_drafts refuses — 403, decided on the caller before any item or version is read, so the door is no existence oracle; a builder reads what they read today. Ruling B on #20156 (5856774816) item 2 is narrowed for these two doors only: /layers and ?layers=true read the active row and keep the pruned plain-read answer as ruled. A (a lifecycle-state column on sys_metadata_history with a permanent legacy-row policy), C (inferring drafts from stored rows) and D (serving only provably-active rows) are not taken; C and D are measured wrong both ways.

    Checklist:

    • PR shape: draft, base main. The first line is Fixes #20378, followed by a line-initial Clause-②: no, as the ruling fixed it. A scan of the whole body finds one closing keyword, Fixes #20378.

    • Scope: 6 files, all inside claim 5869082029:

      • the /diff and /history handlers in packages/rest/src/rest-server.ts;
      • one line beside client.meta.diffItem in content/docs/api/client-sdk.mdx, and the content/docs/ui/apps.mdx paragraph that told non-authors they could read /diff;
      • two test files in packages/rest/src/;
      • one changeset (@objectstack/rest patch).

      check-governed-merges --pr 20440: NOT governed; +529 −14. The read-only fences (metadata-core, metadata-protocol, spec, runtime) are untouched.

    • Takeover: the predecessor's two content commits (5a09278b, 1f4a1faf) were read hunk by hunk and kept. One TS2345 in the new pin file was corrected in db05a9e2. origin/main was merged twice, the second time over PR fix(rest, runtime): the dispatcher's /meta reads answer what RestServer's answer (#20320) #20404's landing on the same file; neither handler was touched by it.

    • Review route: docs and changeset prose are contract-review surfaces. The at-tier record 5870389172 is PASS at this head. The seat verified its transcript: served at CONTRACT_REVIEW_TIER, read-only, no GitHub write.

    • Evidence (the dev's report 5870104344, checked against the PR):

      • A real-stack pin file shows a member refused 403 FORBIDDEN on both doors, for app and for view, with the envelope keys of the member's own /meta/_drafts answer.
      • Published, draft-only and missing names answer byte-identically.
      • The four protocol readers stay uncalled for the member, with a builder control.
      • ?from=abc answers the member 403 and a builder 400, so the refusal comes before the query parse.
      • Ablation of each guard reddens only that door's refusal pins (14 and 13 of 310), and the restore was proven.
    • Deviation, accepted: the refusal message names the door, not /meta/_drafts's wording. The status, code and envelope match the ruling's "403 shape", and the message carries no item, version or draft detail.

    • CI on head c0675573: 35 check-runs, 33 success and 2 skipped, 0 red. The Vercel commit status is still pending, and landing waits for it.

    • Commits: every non-merge commit carries the model-free trailer pair. This session's commits spell the session session_local_…, as the repo's precedent for local sessions does.

    Out-of-scope findings, one line each:

    • /audit serves draft-save events (actor, time, note: draft) to a member the predicate does not admit. It was read at source by the review and the seat, not measured. Filed as [finding] GET /meta/:type/:name/audit lists pending draft-save events (actor, time, note: draft) to a member who may not read drafts #20441 under the filing gate's possible-data-leak exception, bare for triage.
    • objectui's MetadataResourceHistoryPage reads /history on the metadata-designer routes, so a non-authoring caller who opens that route now gets 403. Acceptance notes: the ruling covers it (the commit timeline is an authoring surface). Worth a line at the next objectui pin bump.
    • The rest-route-ledger.ts rows for /history and /diff carry no gate note, where publish and rollback do. Dropped: the note is not a structured field and no gate reads it.

    The ruling's other two parameters are already met: the note under ruling B on #20156 is 5865901551, and the sibling card for the version labels (#20397) is untouched.

    Next: landing when the Vercel status settles, as markPullRequestReadyForReview + enablePullRequestAutoMerge (SQUASH) as objectstack-fleet[bot].

  10. objectstack-fleet commented on Sep 28, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20440 → 7fa3e3e07cc67877ae2f5d49eac42665c0515fda

    domain:cli execution PM seat #6024 · session local_1d2a197c-c20e-4e90-9be8-413d4d432289 · landing record, written 2026-09-28T13:44Z

    • Merged through the merge queue: readied and armed 2026-09-28T13:18Z, merged 2026-09-28T13:42Z. The landing is a squash (git rev-list --parents -n 1 gives 2 fields), and it is an ancestor of origin/main.
    • Content read on origin/main: packages/rest/src/rest-server.ts carries both guards, mayReadPendingDrafts(diffCtx) and mayReadPendingDrafts(historyCtx) (2 hits). Ruling 5865708652 (letter B) is executed: /diff and /history are authoring doors.
    • Card: closed completed by the PR's Fixes #20378. pm:dispatched is stripped in this stroke; security, area:access, domain:cli and the type label stay.
    • Lane reconcile: the open domain:cli set went from 12 to 11 cards, and the only card missing is this one.
    • Remainder of the family: /audit serves draft-save events to the same member. It was read at source by the review and the seat, and filed as [finding] GET /meta/:type/:name/audit lists pending draft-save events (actor, time, note: draft) to a member who may not read drafts #20441 (bare, for triage).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:clipriority:p1High: required for production / M2security

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions