Skip to content

feat(client, rest): bind both getHistory exits to HistoryMetaItemResponse; ledger row names the schema - #16694

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-13523-history-door-schema-rebind
Sep 7, 2026
Merged

feat(client, rest): bind both getHistory exits to HistoryMetaItemResponse; ledger row names the schema#16694
os-sales merged 3 commits into
mainfrom
claude/issue-13523-history-door-schema-rebind

Conversation

@os-sales

@os-sales os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #13523

Clause-②: no

Clause-② — ruled yes by the dispatching PM (#6024), on the condition its own Claim: set

The claim (5574952927) declared Clause-② conditionally: yes if the hand-written inline shape and z.infer<typeof HistoryMetaItemResponseSchema> differ, no if they are byte-equivalent. The delivered changeset measures them as differing, so the condition resolves yes:

member inline shape (old) published HistoryMetaItemResponse
actor string nullable — the door answers null for every system-initiated write (boot sync, migration, scheduled job), and the schema declares it "never a sentinel string"
op plain string the closed ADR-0008 §2.4 verb union (create/update/delete/rename/publish/revert)
version, previousName, ref.version absent reachable
ref.org org?: string (optional) required — what the producer always writes
the scoped twin no annotation, _unwrap with no type argument ⇒ Promise<unknown> the same named type as the unscoped exit

⇒ the published return type narrows. Same shape as #14313, and the maintainer's #12104 ruling (5472614711) puts this class at Clause-②: yes explicitly. The changeset is graded @objectstack/client: minor / @objectstack/rest: patch and carries BREAKING (types) in its own body.

The two consumers bound

  1. Route-ledger rowpackages/rest/src/rest-route-ledger.ts, the GET /api/v1/meta/:type/:name/history entry now carries responseSchema: 'HistoryMetaItemResponseSchema', joining its already-bound audit / rollback / diff siblings. Data only, in a package-internal module: no route, handler or emitted byte moves. This row was the one member of the metadata family left unfilled, because the declaration (MetadataProtocol declares no historyMetaItem member, so the REST history door's request literal is compiled against nothing — the last undeclared read door of the audit/history pair #12005, PR feat(spec): declare MetadataProtocol.historyMetaItem and de-cast the REST history door #13521) landed after the row was written.
  2. Both getHistory exits in packages/client/src/index.ts — the unscoped ObjectStackClient.meta.getHistory and the scoped ScopedEnvironmentClient.meta.getHistory, each now annotated Promise<HistoryMetaItemResponse> with the unwrap call given the matching type argument.

The two getHistory occurrences: measured, not assumed

Triage flagged binding only one as "this card's most likely error" and asked for the relationship to be measured. They are two exits of ONE door, not two doors.

Located by symbol (⛔ never by the stale line numbers — the file moved under #15445, #14526 and #16676): packages/client/src/index.ts holds exactly two getHistory definitions, one on ObjectStackClient.meta and one on ScopedEnvironmentClient.meta.

The producer side proves they are one mount. In packages/rest/src/rest-server.ts, registerMetadataEndpoints(bp) is called from inside the registerForBase(bp) closure, and registerForBase is invoked twice — once with basePath and once with scopedBase (${basePath}/environments/:environmentId). Inside it, isScoped is derived from that same string (basePath.includes('/environments/:environmentId')) and registerPerItemRoute registers one handler body for the /:type/:name/history path under both bases; the only difference is whether environmentId is read from req.params. ⇒ one handler, replayed against two prefixes, answering a byte-identical body.

Both exits are bound. Their declared states before this change were nevertheless different — the unscoped carried a hand-written inline object type, the scoped carried nothing (Promise<unknown>) — so binding one and leaving the other would have relocated the divergence rather than removed it. return-type-precision.test.ts pins UnscopedHistory ≡ HistoryMetaItemResponse, ScopedHistory ≡ HistoryMetaItemResponse and UnscopedHistory ≡ ScopedHistory; that third assertion is the one that refuses a half-fix, and the ablation below shows it is red for either half alone.

Gates — measured at head cd9f93413eb577817fdfa9d0ad8cb7867dcd079b

Every reading below is against that commit, working tree clean (git status --porcelain empty, git diff HEAD empty). Each exit code was captured before any pipe; no verdict is read from a ;-sequenced batch.

origin/main moved to 001a83b048 during this run. It was not re-merged: its one new commit (#16650) touches packages/drivers/*, packages/services/*, packages/types/* and adds two new test files under packages/rest/src/ — it overlaps none of this PR's five files, so no merge was needed and the head above is the head that was measured.

Measured green

gate result
dispatch-gates --commands derived family — 58 commands, all run individually 58/58 exit 0
dispatch-gates --ran ran.list reconciliation ✓ 58 derived famil(ies) accounted for — 58 run, 0 NOT-MEASURED, 0 UNRUN
pnpm --filter @objectstack/client typecheck exit 0 — tsc --noEmit clean; check:test-typecheck OK, 0 files / 0 errors in test-typecheck-debt.json
pnpm --filter @objectstack/rest typecheck exit 0 — same, 0 files / 0 errors
@objectstack/client unit tier (pnpm --filter @objectstack/client test) exit 0 — 35 files, 454 tests passed
the two touched test files run explicitly exit 0 — 2 files, 20 tests passed
pnpm exec eslint . --no-inline-config (whole repo) exit 0, zero output

The ran.list was built from the byte-for-byte command strings --commands printed (verified diff-identical to the harvested list), never from log file names.

NOT MEASURED — with each gate's own stated prerequisite

⚠️ dispatch-gates --ran's 0 NOT-MEASURED is the runner's own accounting of the derived set, not verification of everything CI runs. The honest split:

  • check-changeset-no-major LEVEL AXIS — exit 0 overall, but the gate itself prints: "LEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR… no pull_request payload was available to read a declaration from." Prerequisite: a live pull_request payload, which exists only once this PR does. CI measures it on this PR.
  • 5 families that take a value from the workflowcheck-shard-attestation.mjs (×3, ${{ matrix.shard }}) and check-test-completeness.mjs (×2, $RUNNER_TEMP/*.log). Prerequisite: a CI run; there is no local invocation. Named by the tool as ⊘ NOT MEASURED and deliberately outside the 58.
  • 10 families declaring a population too wide to placecheck:init-service-contract, check:live-db-isolation, check:meta-type-normalized, check:optional-error-sink, check:resume-authority-declared, check:runner-env-posture, check:settings-bind-window, check:startup-registry-verdict, check:verify-stand-in, check:wildcard-fallthrough. lint.yml carries no paths filter, so CI runs them on every PR; no path derivation can say whether they bite. Their absence from the derived total is not a clearance.
  • 41 artifact-roster families scored silent — a fact about a tracked-file baseline, not about these paths (4 of them keep that roster in a directory one of these paths is in). Not clearance in either direction.
  • The always-runs step tail (workflow steps with no path filter, e.g. pr-labels.mjs, the publish smoke script) — outside --commands by construction, run by CI.

Zero gates hit exit 3, zero were killed by the OS, zero fell to UNRUN.

Ablation — the new pin is proved able to fail

return-type-precision.test.ts is the guard for this whole card, so it was ablated rather than trusted. Nothing was committed: the committed tree was mutated in place by exact-anchor replacement that refuses on anchor count ≠ 1, the mutation was proved on disk by occurrence counts and git hash-object, and restoration was proved by blob hash and an empty git diff HEAD — never by an exit code. A trap … EXIT INT TERM with absolute paths held the restore.

Pristine blob of packages/client/src/index.ts: 7fa9e129ca1aa66c8bb099a243351e9b73389606.

row mutation (exact anchors, each count = 1) mutated blob on-disk proof source tsc --noEmit pin program check:test-typecheck vitest on the pin file
A unscoped exit only → the OLD inline return shape + untyped unwrapResponse(res) 9e7640b5ee… Promise<HistoryMetaItemResponse> 2→1; old actor: string; 0→1; unwrapResponse<…> 1→0 exit 0 (clean) exit 1 — RED, 9 type errors in src/return-type-precision.test.ts exit 0
B scoped exit only → no annotation + untyped _unwrap(res) 12e1014d8f… Promise<HistoryMetaItemResponse> 2→1; _unwrap<…> 1→0 exit 0 (clean) exit 1 — RED, 3 type errors in src/return-type-precision.test.ts exit 0
A+B both exits reverted (the pre-change state) 615a6ac70b… Promise<HistoryMetaItemResponse> 2→0; both unwrap type args gone exit 1 — TS6133 'HistoryMetaItemResponse' is declared but its value is never read (a side effect of the full revert, not the pin) exit 1 — RED, 11 type errors in src/return-type-precision.test.ts exit 0
restore back to 7fa9e129ca…

Restoration after every row: blob hash matched pristine and git diff HEAD was empty. Final state: git status --porcelain 0 lines, HEAD still cd9f93413e, git hash-object packages/client/src/index.ts = 7fa9e129ca….

Two readings worth stating plainly:

  • Rows A and B are the half-fix test, and both are red. Binding one exit and leaving the other does not get past this pin. That is precisely the error triage warned this card was most likely to make.
  • vitest is green in every row, and that is correct, not a hole. The pin is type-level on purpose: expectTypeOf and @ts-expect-error are erased at runtime, so the value the stubbed transport returns is identical either way. The teeth are in check:test-typecheck (which compiles the test layer under packages/client/tsconfig.test.json), and the pin file's own docblock says so. A runtime-only reading of this pin would have reported a false green three times.

Serial scan — packages/client/src/index.ts is clear

Re-scanned at claim time as the card demands, ⛔ not taken from any comment. All 17 open PRs enumerated and paged to the last page (list_pull_requests state=open perPage=100; page 1 returned 17, page 2 returned empty — the paging failure that cost this card two days is exactly what that second call exists to rule out), then each scanned per ref: git fetch --depth=80 origin refs/pull/N/head, git merge-base FETCH_HEAD origin/main, git diff --name-only <merge-base> FETCH_HEAD.

Result: every one of the 17 refs fetched and resolved a real merge base within depth; none holds packages/client/src/index.ts. The only open PR touching packages/client/ at all is the changeset-release bot (#15334), and only CHANGELOG.md / package.json — same-package, which is exempt in this lane; same-file is the hard-serial rule and nothing holds it. The four cards historically queued on this file (#14313, #14314, #15451, #14879) have no open PR: #14879's carrier PR #16676 merged at 19:32Z, and none of the 17 open heads is any of the others.

Method validated rather than assumed: the git per-ref file list for the largest open PR (#16628, 37 files) is byte-identical to GitHub's own get_files list for it.

Scope

⛔ Out of scope, preserved from the card: the dead environmentId wire-spread on the same door belongs to #12005, not this card. Untouched here.

No wire byte moves. HistoryMetaItemResponseSchema is a describe-only transcription of what historyMetaItem already returned, and the SDK's runtime path is unchanged — only what the compiler knows about it.


Generated by Claude Code


Generated by Claude Code

…onse; ledger row names the schema

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
The `note` field ships to authors and operators through generated
surfaces, where an issue id resolves to nothing. The anchors move to an
adjacent source comment, which only a reader who can resolve them sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
@github-actions github-actions Bot added the size/m label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/client, @objectstack/rest, touching 6 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via /environments/:environmentId (route, a path literal in meta))
  • content/docs/concepts/north-star.mdx (via /environments/:environmentId (route, a path literal in meta))
  • content/docs/deployment/publish-and-preview.mdx (via /environments/:environmentId (route, a path literal in meta))
  • content/docs/deployment/single-project-mode.mdx (via /environments/:environmentId (route, a path literal in meta))
  • content/docs/kernel/contracts/metadata-service.mdx (via getHistory (sdk, the bare tail of client method meta.getHistory, bound to GET /api/v1/meta/:type/:name/history))
  • content/docs/plugins/adding-a-metadata-type.mdx (via /meta/:type/:name/history (route, a path literal on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /environments/:environmentId (route, a path literal in meta))
  • content/docs/protocol/kernel/metadata-service.mdx (via /environments/:environmentId (route, a path literal in meta))
  • content/docs/ui/forms.mdx (via /environments/:environmentId (route, a path literal in meta))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via /environments/:environmentId (route, a path literal in meta))

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: responseSchema (6 routes)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.

Coarse fallback — 22 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 001a83b0486391847d45f6866896c53ad8714569packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3ac8bd89f96104a667be73cc7c084bcfb60c8b90 — the merge of head cd9f93413eb577817fdfa9d0ad8cb7867dcd079b into base 001a83b0486391847d45f6866896c53ad8714569, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3ac8bd89f96104a667be73cc7c084bcfb60c8b90 && git checkout 3ac8bd89f96104a667be73cc7c084bcfb60c8b90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 001a83b0486391847d45f6866896c53ad8714569 cd9f93413eb577817fdfa9d0ad8cb7867dcd079b && git checkout -B drift-repro 001a83b0486391847d45f6866896c53ad8714569 && git merge --no-ff cd9f93413eb577817fdfa9d0ad8cb7867dcd079b

node scripts/docs-audit/affected-docs.mjs --json 001a83b0486391847d45f6866896c53ad8714569

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 001a83b0486391847d45f6866896c53ad8714569 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 7, 2026
@os-sales os-sales added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 7, 2026 — with Claude

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Check Changeset 红,已定位;⛔ 本席暂不推修,因为正确的修法取决于一小时内会落定的裁定

domain:cli 执行 PM 席(#6024),R70。头 cd9f93413e。这是本席自己开的 PR,按 drive-to-green 本条即是「说清楚什么在挡、需要什么」的那一条评论。

失败读数(读的是 job log,不是事件摘要)

✓ This diff introduces no `major` bump.
⛔ This PR declares clause-② YES and grades a package it grew `patch`.

   .changeset/history-door-schema-rebind.md
     - @objectstack/rest: patch   ← this PR moves @objectstack/rest's packages/*/src/**

The two declarations disagree, inside one PR:
   · carrier: `needs:contract-review` IS on this PR
   · declaration line: `Clause-②: yes`

⚠️ 点名的不是 @objectstack/client(它是 minor,没问题),@objectstack/rest。触发的原因是本 PR 动了 packages/rest/src/rest-route-ledger.ts —— 落在 packages/*/src/** 之下,于是档位轴判定该包「长过」。

⛔ 五条红是同一次失败的回声(PR 开启 + 本席两次挂标各触发一次重跑),不是五个问题。⛔ 不是 flake,不需要 re-run:它是确定性的,同一棵树上每次都红。

为什么现在不推修

闸自己给了两条路,并写明二者不可互换:

  1. 声明对、档位错 → 抬到 minor
  2. 档位对、声明错 → 在生产者处改正。
    ⛔ Do not add a tolerance here to route around a declaration that says something its author did not mean.

⇒ 走哪一条,取决于 Clause-② 究竟是不是 yes —— 而那正是此刻达档(fable)复核在裁的东西,本席已明确授权它判 no

  • 若复核确认 yes ⇒ 走第 1 条:把 @objectstack/rest 抬到 minor
  • 若复核no ⇒ 两侧载体一笔剥除、改判 Claim:,耦合随之消失,changeset 一个字都不用动,闸自绿。

⚠️ 此刻就把 rest 抬到 minor,是在一个可能一小时内被推翻的声明上,对发布做一次真实改动;若随后被推翻,还要再回退一次。⇒ 等裁定是更省的一步,而不是拖延。⛔ 本席也不会去加容差绕开它——闸把这条明写为禁止。

⭐ 顺带:这是同一条判据今天第二次咬本席,而且这次是反方向

今早 #16672 上,Clause-②: yes + patch 被这条耦合判红,那次是对的。

而这一次值得记下来的是判据的形状:rest-route-ledger.ts 是一个包内模块里的数据行 —— changeset 自己写着「no route, handler or emitted byte changes」。它没有长出任何公开面。但因为它住在 packages/rest/src/** 之下,档位轴就把它算作「长过」。

⚠️ 而本席今晚早些时候立的 #16692 记的是同一条判据的另一头:judgeLevel 只经 packages/*/src/** 认定「长过」,所以一个出货的 bin/ 目标它看不见。

⇒ 合起来看:同一个路径判据既漏判出货面、又误判内部数据行。已把这个反方向的读数补进 #16692。⛔ 本 PR 不改闸、不加容差 —— 那是 #16692 的事,不是这张卡的。


Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

契约复核 PASSClause-②: no,推翻本席的 yes;载体已清,正文声明已改写

domain:cli 执行 PM 席(#6024),R70。Reviewed-by: 达档契约复核(CONTRACT_REVIEW_TIER,claude-fable-5-1),头 cd9f93413e。整份采纳。改判 Claim:#13523 (comment)

$ node scripts/pm/check-clause2-carriers.mjs --pair 16694
✓ PR #16694 / card #13523 — both carriers agree.    EXIT=0

⛔ 先记本席在执行上的一次失手,而不是等它被别人发现

本席把「一笔」做成了半笔。 先摘了卡片侧的载体,然后转去改 PR 正文,忘了摘 PR 侧 —— 于是 check-clause2-carriers 立刻判 exit 4:

✗ C1 — needs:contract-review on delivering open PR #16694 while card #13523 does NOT carry it — the same split, written from the other end.

⇒ 这正是双载体纪律存在的目的所要防的那种劈裂,而制造它的是本席。⚠️ 并且按闸自己的话:

a legitimate clear leaves two removals seconds apart, a strip leaves one

本席这两次摘除相隔的是分钟,不是秒。⇒ 证据链因此变弱了一档:它仍然可读为「清」,但不再是那个一眼可辨的形状。⛔ 记在这里,不粉饰。

裁定要点(复核的,非本席的)

两支都不触发。

  • 接受/拒绝支:handler 未动,rest-server.ts:7462 裸发 res.json(result);HistoryMetaItemResponseSchemapackages/rest/srcpackages/client/src运行时零处 parse;台账的 responseSchema 够不到任何运行时或发现面 —— REST_ROUTE_LEDGER 根本未从 packages/rest/src/index.ts 导出
    ⭐ 归谬:编译器对消费者代码的判决不是契约的接受/拒绝行为。若那样读,每一次 .d.ts 更正都是条款②,包括 ADR-0087 D8 专为「使之成为常规」而写的 any→具体 收窄。
  • 公开面支:无新导出符号,已发布载荷无新键,wire body 逐字节不变,其已发布声明(protocol.zod.ts:1567)未动。移动的是 SDK 注解 —— 从一个与已声明契约相矛盾的手写形状(actor: string,而 rowToEventnull),回到那份已声明的契约本身。⇒ SKILL.md:393 后半句逐字:「拉回已声明契约 ⇒ 代裁车道」。

同形先例 PR #12848(#12038 交付):16 个方法、18 条台账行,完全相同的动作,无内容支声明、无载体,@objectstack/restpatch

⭐ 本席错在援引越界,而非读错判据

本席称维护者 #12104 把这一类明写为 yes。复核读了原文:那句在 「执行形状……按族三张卡 —— organizations 19 / auth 14 / oauth 5……每卡 Clause-② yes」 之内 —— 「每卡」指那三张。且那三张另有一个与括号无关的独立理由:它们迁到新撰写并导出的 wire 类型,直接命中机械地板的「新导出符号」。本卡不撰写契约、不导出任何东西。

⇒ 判 no 不推翻任何东西。而复核给了本席一条更该记住的程序:

若 PM 认为维护者「本意」是类别规则,正确的动作是提 needs-user-decision,⛔ 不是靠外推去判 yes

⚠️ 这是本席今天第三次条款②过火,且在前两次已被纠正之后。

档位:一个字不改

@objectstack/client: minor / @objectstack/rest: patch 均正确。minor 既是地板也是天花板 —— check-changeset-no-major.mjs:41-45 在发布窗口内机械拒绝 majorrest: patch#12848 同判。

⇒ 那条红自绿:judgeLeveldeclaration.value === 'no' 时返回 not-declared(:940)。⛔ 本席未抬档、未加容差 —— 走的是闸自己给的第 2 条路(声明错,在生产者处改正),而非第 1 条。

⚠️ 正文的 Clause-②: 行必须同笔改写,因为闸读的是 readClause2Line(pr.body) 标签快照(:902);只摘标签,闸仍见不一致。已改,读回确认 line 3 "Clause-②: no"

⚠️ 副作用,已测且不再尝试修

本席经直接 REST PATCH 改正文,平台每次都追加自己的裸脚注 —— 送进 1 个脚注、读回 2 个;再送 1 个、仍读回 2 个,字节数相同。⇒ 重复脚注是通道属性,不是可以靠再改一次消掉的东西。⛔ 停手,不做第三次 PATCH。读数已转记到 #16633(该卡记的正是「同一动作经 MCP 通道不追加」的另一半)。

复核的建议(均不阻塞,已转承接者)

F1 previousName 在这扇门上从未被写入(rowToEvent 不设它)⇒ changeset 不应暗示它被填充;F2 台账注记说 conformance 捕获「carrying every optional member」,而 protocol.test.ts:2166 漏了两个;F3 应写 z.input<> 而非 z.infer<>(ADR-0122);F4 本席措辞不准 —— 破坏性的 actor放宽(stringstring | null),不是收窄。


Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Docs-drift owed item: all 10 rows answered — nothing falsified, nothing pushed

Verdict: Case A. No page in the corpus is falsified by this diff. No commit, no push, no docs edit. The branch head stays cd9f93413eb577817fdfa9d0ad8cb7867dcd079b.

Tree audited

The bot computed on 3ac8bd89f96104a667be73cc7c084bcfb60c8b90 (merge of head cd9f93413e into base 001a83b048). I did not need a detached checkout, because the docs tree is provably the same object:

git rev-parse cd9f93413e:content/docs  -> 6403078d610abb3220bcebe671482c8f65ecb780
git rev-parse 3ac8bd89f9:content/docs  -> 6403078d610abb3220bcebe671482c8f65ecb780
git diff --stat cd9f93413e 3ac8bd89f9 -- content/docs  -> (empty)

Auditing in the claude/issue-13523-history-door-schema-rebind worktree is auditing the bot's tree for content/docs. Corpus: 403 .mdx files under content/docs (438 files total), of which 190 are hand-written per scripts/docs-audit/handwritten-docs.json.

Why the bot could not answer it

Two distinct reasons, one per half of its list:

  1. The getHistory row is an identifier collision. The bot matches bare identifiers. getHistory names two unrelated methods on two different surfaces, and the one the docs page documents is not the one this diff touches. Detail in row 1 below.

  2. The six /environments/:environmentId rows matched a path literal that entered the diff as English prose, not as code. The anchor appears on exactly one added line in the whole diff, and that line is a JSDoc sentence:

    packages/client/src/index.ts +73:
    +     * `registerForBase` replay against `/environments/:environmentId` — so
    

    That is a comment explaining how the scoped exit reaches the same handler. The diff neither creates, moves, nor re-shapes any environment-scoped route. Any page naming that path was always going to match, and matching says nothing.

Searched

Corpus: content/docs/**/*.mdx on tree 6403078d61, all 403 files, grep -rn (ripgrep-equivalent literal + ERE).

Positive control (live, run before the negatives): getHistory over the whole corpus →

content/docs/kernel/contracts/metadata-service.mdx:78

One hit, on the exact page the bot flagged. The instrument reaches the corpus and would have reported the term had it been elsewhere — so the zeroes below are the corpus's zeroes, not the instrument's. Second control: ObjectStack → 181 files. Third: the page's own title string read back out of the file it lives in.

Terms swept (each run corpus-wide, releases/ and generated references/ separated out where noted):

# Term Hand-written hits
T1 previousName 0 hand-written (3 total, all generated references/)
T2 parentHash 1 (concepts/metadata-lifecycle.mdx:80)
T3 event_seq / sinceSeq 1 (concepts/metadata-lifecycle.mdx:216)
T4 MetadataEvent 2 (concepts/metadata-lifecycle.mdx:80,81)
T5 HistoryMetaItem 0 hand-written (all in generated references/api/protocol.mdx)
T6 "actor": / actor: in a sample 0 for this door
T7 bare word actor 0 for this door (all hits are auth / approvals / audit-service / AI ToolExecutionContext / record-view-auditing / webhooks)
T8 /history route path 2 (plugins/adding-a-metadata-type.mdx:226,253)
T9 change.?log all hits are CHANGELOG.md release process, or ADR-0008 architecture prose
T10 ADR-0008 verb union create | update | delete | rename | publish | revert 1 (concepts/metadata-lifecycle.mdx:80)
T11 .getHistory( call sample / .events read 0
T12 sample body with "events" 0 for this door (hits are hook events: arrays and realtime subscription lists)
T13 system-initiated / boot sync / scheduled job null-actor prose 0 hand-written

The emitter-side hand read the bot said it structurally cannot do

The bot's own limitation — "a page that states a rule by its inputs shares no identifier with the emitter that implements the rule" — is real, and the sweep did surface a hand-written page outside its 9:

content/docs/concepts/metadata-lifecycle.mdx:80 (hand-written; not on the bot's list):

- Emits a `MetadataEvent { op, ref, hash, parentHash, actor, seq, ts, source }`, where `op` is one of `create | update | delete | rename | publish | revert`.

Not falsified — it is corroborated. Three reasons: (a) the verb union it spells out matches HistoryMetaItemResponseSchema's new closed enum exactly, member for member — this page is the emitter-side statement of the very narrowing this diff transcribes; (b) it names actor but never types it, so it makes no string-vs-string | null claim to falsify; (c) it describes the internal MetadataEvent emitted by MetadataRepository.put(), not getHistory's return value, and this diff does not touch MetadataEvent. The three members it omits (version, previousName, message) are all optional in the schema, so the brace-list is abbreviated, not wrong.

No edit made: nothing here is this diff's own consequence, and touching it would be widening.

The 9 rows

1. content/docs/kernel/contracts/metadata-service.mdx — via getHistory (the row flagged most-likely). SURVIVES.

Sentence read (line 78, inside the export interface IMetadataService block):

getHistory?(type: string, name: string, options?: MetadataHistoryQueryOptions): Promise<MetadataHistoryQueryResult>;

This is not the method this diff touches. It is the kernel service contract, and the page says so in its own Callout: Source: packages/spec/src/contracts/metadata-service.ts. I byte-compared the doc line against that source:

DOC content/docs/kernel/contracts/metadata-service.mdx:78
SRC packages/spec/src/contracts/metadata-service.ts:802
==> BYTE-IDENTICAL

and git diff --name-only 001a83b048 cd9f93413e -- packages/spec/ returns 0 files. The transcription is exact against a file this PR does not touch.

Decisively, the two getHistorys do not even share a return shape:

Surface Returns
IMetadataService.getHistory (this page) MetadataHistoryQueryResult = { records, total, hasMore }
ObjectStackClient.meta.getHistory (this diff) HistoryMetaItemResponse = { events: [...] }

Different names, different members, different files, different layers. The page names no field of the history door's body — a corpus-wide scan of it for history|actor|previousName|MetadataEvent|change.?log|event_seq|seq|parentHash returns exactly two lines, 72 (a section comment) and 78 (the signature above). Its closing REST-endpoints table lists /meta/:type/:name/publish and /meta/:type/:name/rollback and has no /history row at all.

2. content/docs/plugins/adding-a-metadata-type.mdx — via the /meta/:type/:name/history path literal. SURVIVES.

Sentence read (line 253, under ## Related):

- [`GET /api/v1/meta`](/docs/api) — lists every registered metadata type; per-type items at `GET /api/v1/meta/:type`, history at `GET /api/v1/meta/:type/:name/history`

A cross-link naming the route's path only. No body, no field, no actor, no op, no code sample. The page's two other history mentions (line 8 "version history for every type", line 226 "handles list/edit/create/history") describe what the Studio Metadata Admin renders, not what the door answers. A response-shape rebind cannot falsify a path literal.

3–8. The six /environments/:environmentId rows. ALL SURVIVE — each is about environment routing generally, as suspected. Sentence read for each:

  • content/docs/api/environment-routing.mdx (line 5): "Environment-scoped routing makes the target runtime explicit in the URL:" followed by a fenced block of four path templates (/data/:object, /meta, /automation/..., /ai/...). A page about the routing mount, listing families not bodies; /history never appears, and its response-prose sweep returns 0.
  • content/docs/concepts/north-star.mdx (line 26): "Runtime identity is environment identity. Use environment, not project, for deployment/runtime scoping: OS_ENVIRONMENT_ID, X-Environment-Id, /api/v1/environments/:environmentId, and environment_id." A naming/vocabulary principle; the path is one of four spellings in a list. Response-prose sweep: 0.
  • content/docs/deployment/publish-and-preview.mdx (line 206, a table row): "Cloud environment preview | Install to a preview environment, then route clients to it via /api/v1/environments/:environmentId/... or X-Environment-Id." A deployment recipe for aiming a client at a preview env. Response-prose sweep: 0.
  • content/docs/deployment/single-project-mode.mdx (line 31, a comparison-table row): "Routing | Direct /api/v1/... routes | Optional /api/v1/environments/:environmentId/..., hostname, header, or session". Single-env vs cloud-aware host contrast. Response-prose sweep: 0.
  • content/docs/protocol/kernel/http-protocol.mdx (lines 90–92): "scoping is added by the REST server, so clients can detect dual-mode routing; environmentId is present only on the environment-scoped mount (/api/v1/environments/:environmentId/...)." About the discovery document's scoping field. Response-prose sweep across all 1355 lines: 0.
  • content/docs/protocol/kernel/metadata-service.mdx — this one does not even contain the anchor. grep -n "environments/:environmentId" returns nothing; the closest is line 204, a different path family with a different param name: "the control plane's public artifact route (/pub/v1/environments/:id/artifact[?commit=<id>]) serves exactly such URLs". That is /pub/v1 + :id, matched only through the bot's param normalization. The page contains zero occurrences of history, seq, op, ref, rollback, audit or diff — it is about MetadataPlugin bootstrap and artifactSource.

9. content/docs/ui/forms.mdx — via /environments/:environmentId. SURVIVES.

Sentence read (lines 170–172, under ## 4. The REST contract):

"Both routes are mounted under the active environment's API base. For a standalone environment that is /api/v1; for scoped deployments it is /api/v1/environments/:environmentId."

"Both routes" are GET /api/v1/forms/:slug and its sibling — the forms door, not the metadata history door. The path is named to explain the mount prefix. Response-prose sweep across all 494 lines: 0.

Release-owned page audit (read-only — not edited, and not proposed for edit)

content/docs/releases/implementation-status.mdx — audited, not falsified, nothing to file.

The anchor that put it on the list is line 190:

"Every route carries the /api/v1 prefix. When project scoping is enabled each row is additionally mirrored under /api/v1/environments/{environmentId}. The full audited route list lives in packages/rest/src/rest-route-ledger.ts."

Two checks, both pass. First, the sentence's pointer to rest-route-ledger.ts is still accurate: this diff populates a responseSchema field on one pre-existing row and adds a comment; it adds, removes and renames no route, so "the full audited route list lives in" that file remains true and the page's route inventory is unaffected. Second, the page has zero hits for getHistory, /history, actor, HistoryMetaItem, previousName or change.?log across all 545 lines — it never describes this door's response at all. No card needed, no docs-only PR needed.

One negative worth recording

The generated (not hand-written) content/docs/references/api/protocol.mdx already renders the rebind correctly at lines 1808–1830, because it is generated from the zod .describe() strings:

| **actor** | `string | null` | ✅ | Who wrote this. `null` = system-initiated (boot sync, migration, scheduled job) — never a sentinel string… |

That is the widened actor the consumer-visible delta is about, already on the published reference. It corroborates the diff rather than contradicting it, and it is out of scope for a hand-written-docs audit either way. Consistent with the at-tier finding, nothing anywhere in the corpus implies previousName is populated on this door — the only hand-written page that lists event members omits it entirely.

Gates

None run, and none owed: Case A means no file changed, so there is no tree to gate and no empty commit was made. Head is unchanged at cd9f93413eb577817fdfa9d0ad8cb7867dcd079b; docs tree 6403078d610abb3220bcebe671482c8f65ecb780.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants