Skip to content

fix(metadata-protocol)!: /diff refuses a version with no history row 404 VERSION_NOT_FOUND, as rollback does - #26540

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-91nyz6
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-91nyz6

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26462

Clause-②: no (narrowing) — measured on this diff: diffMetaItem and the REST /diff door that forwards to it refuse a named from / to version with no history row of the item (404 VERSION_NOT_FOUND), which answered 200 before. The default range and every version that has a row answer as before. No error code is added (VERSION_NOT_FOUND is already in ERROR_CODE_LEDGER), no export is added to a package entry, and nothing under packages/spec changes.
Metadata-protocol: none
Skills-docs: none — no skills/** file or docs page teaches the /diff answer for a version that has no row; the client SDK page shows a range of versions that exist.

维护者速读

  • 改了什么:元数据版本对比接口 GET /api/v1/meta/:type/:name/diff 遇到不存在的 from / to 版本号时,改为返回 404 VERSION_NOT_FOUND(与回滚接口同一句话),不再返回 200 的“没有变化”或“全部被删除”。
  • 为什么改:输错或已清理的版本号会让作者和审核人误以为两版相同或条目已删除;回滚接口对同一个号码早已拒绝,ADR-0110 D3 也不允许把“查不到”说成事实。
  • 风险与回滚:只影响显式指定了不存在版本号的调用;不带 from / to 的默认对比、存在的版本(包括删除墓碑)都不变。回滚即 revert 本 PR。
  • 先看:packages/metadata-protocol/src/protocol.ts(diffMetaItem 两处取版本)、packages/metadata-protocol/src/sys-metadata-repository.ts(共用的 versionNotFoundError)、packages/rest/src/meta-history-diff-authoring-door.test.ts(线上 404 与原来用 from=0 的对照测试)。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.diff-missing-version.test.ts,预期 11 个用例全绿;在 main 上同一文件 7 个转红。
  • 需要你做的:无,席位落地。

What changes

  • diffMetaItem (packages/metadata-protocol/src/protocol.ts) no longer reads a named version with byVersion.get(v) ?? null. A from or to with no history row of the item throws the rollback door's refusal: 404 VERSION_NOT_FOUND, No history row at version N for TYPE/NAME. The edit stays in place inside the method; no code moved.
  • The refusal is not minted twice. SysMetadataRepository.restoreVersion built it inline; it is now versionNotFoundError(ref, version) in sys-metadata-repository.ts, which restoreVersion and diffMetaItem both throw. It is not exported from the package entry, like packagelessAddressAmbiguityRefusal beside it. This file is outside the claim's declared file surface; it is the shared constructor the order asks for ("reuse the rollback door's refusal … do not mint a second one").
  • A delete tombstone is a row: the check is on undefined from the map, not on a null body, so a tombstone is still diffed as an empty side.
  • The REST route is unchanged. Its catch already hands the throw to handleRouteError, which answers the same envelope the rollback door answers.
  • The method's docblock gains the @throws line and the unprovisioned-table sentence says what "benign" answers now.

The open question on the card: does a version from ANOTHER item's history leak through? No. Measured on main with the stub engine: item qa_diff_probe has v1–v2, item other has v1–v3, and diffMetaItem({ name: 'qa_diff_probe', fromVersion: 1, toVersion: 3 }) answered {"fromVersion":1,"toVersion":3,"added":[],"removed":[{"path":"name",…},{"path":"label","value":"QA Diff"},{"path":"widgets","value":[]}],"changed":[]}. The history read is keyed by (type, name), so other's v3 was never a candidate; it was the same miss read as "everything removed". It is now the same 404, pinned in protocol.diff-missing-version.test.ts.

Tests

  • New packages/metadata-protocol/src/protocol.diff-missing-version.test.ts (11 cases): the step 3 control, steps 4, 5 and 6, a missing from, one side named and missing with the other defaulted, the other-item version, and a parity arm comparing { code, status, message } with SysMetadataRepository.restoreVersion's refusal for the same item and version. Controls: a delete tombstone version still answers, and the default range answers unchanged for an item with history and for one with none.
  • New REST case in packages/rest/src/meta-history-diff-authoring-door.test.ts, on the real protocol behind RestServer: both missing, to missing, from missing and an item that never existed each answer { status: 404, code: 'VERSION_NOT_FOUND' } naming the version; the step 3 control answers 200 with its label change.
  • Pins whose asserted value moved, each with its subject kept:
    • protocol.diff-canonical-type-and-history-outage.test.ts, "a genuinely-absent table still answers benignly": the [finding] diffMetaItem answers 200 with an empty diff when sys_metadata_history is unreadable — an outage is indistinguishable from "nothing changed" #8833 subject is that an unprovisioned table is not the 503. It now asserts both benign answers: the default range's empty diff, and the 404 miss for named versions (no rows exist).
    • Same file, "does NOT refuse a spelling that reaches for no declared type": the subject is that the type boundary does not refuse fieldz. It now seeds two versions of fieldz/x and asserts the real diff, because with no rows the named versions are a 404 that a weaker assertion could not tell from a boundary refusal.
    • meta-history-diff-authoring-door.test.ts and meta-draft-read-author-exemption.test.ts: three builder controls read the draft by diffing from=0, a version that has no row. They now diff from the published version (v - 1) and read the draft's navigation as the to side of its change, or use the default from side for the draft-only view. What each pins (who reads the draft whole, who reads it pruned) is unchanged.

Acceptance notes

  • from=0 was a test idiom only. No production caller sends it: ui/ has no caller of the metadata client's diff, and the client SDK's diffItem forwards what it is given. The changeset's remedy line names it.
  • The step 6 item was a dashboard on the card. In the REST case I used a view, the type the existing suite boots; the dashboard path reaches the same protocol line.

Verification

Base 45de8c6cb. Final head db2eb1e5: f9ed7619 plus the changeset's Clause-② line moved to no (narrowing), on which the four changeset gates (check-changeset-no-major, check-adr-0087-registration, check-empty-changeset with --base origin/main, and check:changeset-gate-self-tests) re-ran with exit 0. Every other reading below was taken on f9ed7619. origin/main has moved 27 commits since the base; none touches a file this diff touches, and git merge-tree --write-tree HEAD origin/main is clean, so the branch was not merged. CI validates the merge ref.

Red first on base, green on the fix
  • Protocol, protocol.diff-missing-version.test.ts on the base source (the fix not yet committed): Tests 7 failed | 4 passed (11). The 7 are steps 4, 5, 6, the missing from, the one-side-defaulted case, the other-item version and the rollback parity arm, each expected null (the call answered 200). The 4 controls passed. On the fix: Tests 11 passed (11).
  • The other-item probe on base answered removed: [name, label, widgets] of qa_diff_probe v1, not other's body.
  • REST, meta-history-diff-authoring-door.test.ts -t 26462. The suite resolves @objectstack/metadata-protocol through dist/, so the base leg wrote the base blobs of protocol.ts and sys-metadata-repository.ts (marker count on disk 0), rebuilt the package (dist marker files 0), and ran: AssertionError: opportunity.pipeline from 998 to 999: expected status 200, code undefined to deeply equal status 404, Tests 1 failed | 19 skipped. Restored with git checkout HEAD --; git diff HEAD empty and the blob hash equals HEAD's (42c6ce88). Rebuilt (dist marker files 1): Tests 1 passed.
Package suites
  • pnpm --filter @objectstack/metadata-protocol test: before the two pin updates Test Files 1 failed | 268 passed | 3 skipped (272), Tests 1 failed | 21575 passed | 19 skipped; the one red was the plugin-shaped spelling pin (updated, see Tests). The two diff suites after the update: Tests 20 passed (20). pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.
  • pnpm --filter @objectstack/rest test: Test Files 2 failed | 322 passed (324), Tests 3 failed | 6480 passed | 326 skipped before the from=0 controls were updated; the 3 were those controls (expected 404 to be 200). After: both files Tests 26 passed (26) (with the new REST case).
  • packages/objectql protocol-publish-rollback.test.ts: Tests 12 passed (12). packages/runtime meta-app-package-id-key.test.ts: Tests 12 passed (12).
  • eslint --no-inline-config packages/metadata-protocol packages/rest: exit 0.
Full dogfood (the narrowing arm)

pnpm --filter @objectstack/dogfood test under the lock after building its closure, on 75b1f560: Test Files 300 passed | 3 skipped (303), Tests 2532 passed | 18 skipped (2550), VERDICT command-exit 0. The final head differs from 75b1f560 only in the new test file's find double and scripts/engine-double-contract.pinned.json, neither of which dogfood reads.

Derived gates: 69 derived, 68 run with exit 0, 1 NOT MEASURED

node scripts/pm/dispatch-gates.mjs --ran on the final diff: 69 derived famil(ies) accounted for — 68 run, 1 NOT-MEASURED. The first pass found two real reds in the new test's stub engine, both fixed and re-run on the final head: check:engine-double-contract (the ledger had to learn the new pinned doubles, --write) and check:objectql-double-limit (the find double now applies the caller's limit). check:dts-closure, check:dual-build-cjs-loads and check:type-check-debt first read a tree a concurrent closure build was rewriting or could not take the lock behind it; each was re-run on the final head and exited 0. NOT MEASURED: check-plugin-teardown-shape.mjs --self-test, exit 3, its fixture is pinned to a commit this shallow clone cannot reach; CI runs it.

node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 3
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0
pnpm check:durability-log-level :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:error-status-conformance :: exit 0
pnpm check:filter-alias-parity :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:pnpm-flag-spellings :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:scripts-symbol-anchors :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

… history row (red first)

Claude-Session: https://claude.ai/code/session_01FEkQ5pKvmsCDBZABPxvMnu
Co-authored-by: Claude <noreply@anthropic.com>
…history row 404 VERSION_NOT_FOUND

A from/to version the item has no history row at was compared as an empty
side and answered 200 "no change" or "everything removed" (ADR-0110 D3).
It is now refused with the rollback door's own refusal, shared as
versionNotFoundError. The default range is unchanged.

Claude-Session: https://claude.ai/code/session_01FEkQ5pKvmsCDBZABPxvMnu
Co-authored-by: Claude <noreply@anthropic.com>
…m rows of its own

Claude-Session: https://claude.ai/code/session_01FEkQ5pKvmsCDBZABPxvMnu
Co-authored-by: Claude <noreply@anthropic.com>
…wire; the from=0 controls read from the published version

Claude-Session: https://claude.ai/code/session_01FEkQ5pKvmsCDBZABPxvMnu
Co-authored-by: Claude <noreply@anthropic.com>
…s the caller's bound; ledger learns its pinned doubles

Claude-Session: https://claude.ai/code/session_01FEkQ5pKvmsCDBZABPxvMnu
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 8 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via diffItem (sdk, the bare tail of client method meta.diffItem, bound to GET /api/v1/meta/:type/:name/diff), meta.diffItem (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/diff, selected by route anchor /:type/:name/diff))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via diffMetaItem (symbol, a method of class ObjectStackProtocolImplementation), /:type/:name/diff (route, bridged from symbol diffMetaItem — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via /:type/:name/diff (route, bridged from symbol diffMetaItem — its route source's handler names it))

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
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • 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 — 11 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 185512ccaf6394364a0da1af55cd8f1bfae37869 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from daf92bef44a17caea337e08698e19b7d269465d5 — the merge of head db2eb1e5967c3a7174db23dbe2b605d6bdbbbfd9 into base 185512ccaf6394364a0da1af55cd8f1bfae37869, 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 daf92bef44a17caea337e08698e19b7d269465d5 && git checkout daf92bef44a17caea337e08698e19b7d269465d5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 185512ccaf6394364a0da1af55cd8f1bfae37869 db2eb1e5967c3a7174db23dbe2b605d6bdbbbfd9 && git checkout -B drift-repro 185512ccaf6394364a0da1af55cd8f1bfae37869 && git merge --no-ff db2eb1e5967c3a7174db23dbe2b605d6bdbbbfd9

node scripts/docs-audit/affected-docs.mjs --json 185512ccaf6394364a0da1af55cd8f1bfae37869

⚠️ 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 185512ccaf6394364a0da1af55cd8f1bfae37869 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: db2eb1e5967c3a7174db23dbe2b605d6bdbbbfd9
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/sys-metadata-repository.ts, packages/metadata-protocol/src/protocol.diff-missing-version.test.ts, packages/metadata-protocol/src/protocol.diff-canonical-type-and-history-outage.test.ts, packages/rest/src/meta-history-diff-authoring-door.test.ts, packages/rest/src/meta-draft-read-author-exemption.test.ts, scripts/engine-double-contract.pinned.json + packages/rest/src/meta-item-history-routes.ts (the /diff and /rollback doors, read at the head), packages/rest/src/error-response.ts (handleRouteError / resolveErrorResponse, the envelope the throw rides), packages/metadata-protocol/src/index.ts (the package entry: versionNotFoundError is not on it), packages/metadata-protocol/package.json (one . export, no subpath), packages/spec/src/api/error-code-ledger.zod.ts (VERSION_NOT_FOUND already ledgered for @objectstack/metadata-protocol), packages/client/src/index.ts (diffItem forwards from/to as given), ui/packages/data-objectstack/src/metadata-client.ts (diff, no caller in ui/), packages/rest/src/rest-server-query-number-reads.test.ts (the from=0 → fromVersion: 0 forwarding pin, on a mocked protocol), packages/metadata-protocol/src/protocol.diff-dead-history-read.test.ts and packages/objectql/src/protocol-publish-rollback.test.ts (every other explicit-version diff pin seeds its rows), scripts/check-changeset-no-major.mjs and scripts/pm/clause2-line.mjs (ruling A level axis, the (narrowing) arm), .claude/skills/pm-dispatch/references/execution-duties.md line 106, content/docs/api/client-sdk.mdx and content/docs/references/api/rest-server.mdx (the only docs naming /diff).

Read 2026-10-27T07:25Z: card #26462 body and all 3 comments (triage 6434115388, claim 6434783381, os-dev-report 6436042712); PR #26540 body, its 8-file list, git diff origin/main...db2eb1e5 (+429/−26); the check-runs on db2eb1e5.

① Derived judgments

  1. diffMetaItem accept set narrows: a named fromVersion / toVersion with no history row of (type, name) now throws instead of comparing an empty side — RIGHT. protocol.ts 27363–27377 and 27422–27429 replace byVersion.get(v) ?? null with a === undefined test and throw versionNotFoundError({ type: singularType, name }, v). byVersion is built from histRows, which stores a delete tombstone as body: null (27451–27455), so Map.get answers null (served, compared as {}) for a tombstone and undefined only for a missing row. The tombstone control in protocol.diff-missing-version.test.ts pins that distinction. Both sides are checked in the order to then from, so "both missing" names to (999 in the card's step 4), which is the version the REST case asserts.
  2. The default range is never refused — RIGHT. The request.toVersion === undefined branch (active-row read, 27378–27420) and the request.fromVersion === undefined walk (27430–27467) are untouched; an item with no history and no from/to still answers { fromVersion: null, toVersion: null, added: [], removed: [], changed: [] } (pinned). The 503 outage path (rethrowUnlessMetadataStoreUnprovisioned) is untouched; with an unprovisioned table histRows is [], so the default range answers the benign empty diff and a named version is the 404 miss, which the docblock and the rewritten [finding] diffMetaItem answers 200 with an empty diff when sys_metadata_history is unreadable — an outage is indistinguishable from "nothing changed" #8833 pin now say explicitly. Both benign answers remain distinct from the 503: ADR-0110 D3 holds on both sides.
  3. One refusal, not two — RIGHT. versionNotFoundError(ref, version) in sys-metadata-repository.ts 544–554 builds Error & { code: 'VERSION_NOT_FOUND'; status: 404 } with the sentence restoreVersion built inline before (No history row at version N for TYPE/NAME., byte-identical to the removed lines 1637–1644). restoreVersion line 1661 now throws it; its tombstone (VERSION_NOT_RESTORABLE 409) and bundled-member (VERSION_NOT_FOUND with the member sentence) arms are unchanged. The parity arm in the new test compares { code, status, message } of the two doors on the same item and version.
  4. Public surface: no export added — RIGHT. versionNotFoundError is a module export of sys-metadata-repository.ts imported by protocol.ts (line 60); index.ts 142–149 exports only SysMetadataRepository, resetEnvWritableMetadataTypes and five types from that module, and package.json has the single . entry. VERSION_NOT_FOUND is already in ERROR_CODE_LEDGER for @objectstack/metadata-protocol (error-code-ledger.zod.ts 719); no error code, schema key, or packages/spec file is added or changed (the lane red line holds).
  5. The REST /diff door narrows with it, with no route edit — RIGHT. meta-item-history-routes.ts 998–1004 forwards fromVersion/toVersion to diffMetaItem inside the handler's try, whose catch calls handleRouteError(res, error); resolveErrorResponse passes a thrown 4xx status + code through (error-response.ts 366, 589–592), the same path the rollback door's step-7 404 rides. readDeclaredQueryNumber still forwards from=0 as fromVersion: 0 (query-number-reads pin 288, on a mocked protocol, so that pin is unaffected), and the protocol now refuses 0 as a version with no row: the changeset names from=0 under "Who sees it". One nuance the changeset does not state and need not: for a type whose per-caller gate reads the current document and does not gate on its own key, a never-stored item is answered by sendMetaItemAbsent at 985–991 before the protocol is reached; that is pre-existing (it was not a 200 before either) and the card's dashboard and the test's view gate on their own key (rest(/meta read gate): enforce a list view's and a dashboard's requiredPermissions on the list read and the by-name read, so each audience is served only its own views and boards (the rest half of #22611) #22639), so the card's step 6 reaches the new 404.
  6. The "other item's version" question — RIGHT, and answered by code, not only by test. The history read is where: { type, name } (27441–27448) then historyRowsOfMember, so another item's rows are never in byVersion; on main the probe was the "everything removed" miss, now the 404. The new test seeds other v1–v3 with a secret_marker and asserts the message names dashboard/qa_diff_probe and not other.
  7. Pins whose asserted value moved — each keeps its subject, RIGHT. (a) protocol.diff-canonical-type-and-history-outage.test.ts [finding] diffMetaItem answers 200 with an empty diff when sys_metadata_history is unreadable — an outage is indistinguishable from "nothing changed" #8833 "genuinely-absent table": subject is "not the 503"; now asserts the default range's empty diff AND the named-version 404 under MISSING_TABLE_ERROR, a stronger pin. (b) Same file, diff-verb-skips-the-canonical-fold: diffMetaItem answers an EMPTY diff for a manifest-absent type addressed with a plural #8868 fieldz: subject is "the boundary does not refuse a plugin-shaped spelling"; with no rows the named versions would be a 404 that reads like a refusal, so it now seeds two versions and asserts REAL_DIFF_BODY, which proves the served path rather than an empty answer. (c) The three REST builder controls that read a draft via from=0 now diff from v-1 (reading navigation as the to side of its changed entry) or use the default from; who reads the draft whole vs pruned is unchanged. from=0 appears in no production caller: packages/client diffItem and the ui metadata client forward what they are given, and ui/ has no caller of diff.
  8. New REST case on the real protocol — RIGHT. Four 404 VERSION_NOT_FOUND arms (both missing, to missing, from missing, never-existed item) each assert the missing version in the text, with the step-3 control at 200 carrying label under changed.
  9. Stub-engine ledger — RIGHT. scripts/engine-double-contract.pinned.json gains the three (delete, findOne, update) entries for the new test file, matching the assertEngine* calls in its stub; the stub's find applies limit, which check:objectql-double-limit requires. Both gates are in the derived list run green on the PR body; the Lint · repo & tooling gates and Lint · PM dispatch-gates check-runs are success.
  10. Every other explicit-version diff pin in the repo seeds its rows (protocol.diff-dead-history-read.test.ts 255–285 seedTwoVersions, 340–354 seedLineage v1–v3; objectql protocol-publish-rollback.test.ts 338–354 saves two versions), so no sibling suite depends on the old empty-side answer. The one ?from=0&to=N idiom lived only in the three REST controls this diff rewrote.

Nothing wrong found. One doc-precision note, not a defect: the changeset's "An item that never existed answered 200 'no change'" is the protocol door's answer and the REST door's for types gating on their own key (the card's case); it is not claimed for every type, and the "Now" and "Unchanged" lines are accurate as written.

② Semver level

.changeset/26462-diff-missing-version-refused.md: '@objectstack/metadata-protocol': patch, title fix(metadata-protocol)!: …, **BREAKING** accept-set narrowing, Clause-②: no (narrowing), Metadata-protocol: none, and the ADR-0087 comment adr-0087: not-required (no-migration-prescription).

  • Package set — RIGHT. The only published source that moves is @objectstack/metadata-protocol (protocol.ts, sys-metadata-repository.ts). @objectstack/rest changes tests only; its door narrows through the dependency, and the changeset names that door. check-empty-changeset and both Check Changeset check-runs are success.
  • Level — RIGHT under ruling A. check-changeset-no-major.mjs 901–922: the level follows the Metadata-protocol: line alone; none ⇒ every introduced entry patch, and "a non-protocol … narrowing ships patch and keeps its BREAKING banner and its ADR-0087 disposition". Nothing an author writes changes (no authorable-surface or defaults shard moves), so none is true and patch is the level the gate requires, not merely permits.
  • Clause-②: line — RIGHT. The PR body and the changeset both carry Clause-②: no (narrowing): no surface is widened, a published accept set is narrowed, and (narrowing) is the arm clause2-line.mjs 113–116 defines for exactly this ("NOT a widening, but breaking"), the arm the ADR-0087 gate reads (check-adr-0087-registration.mjs 712) and the arm record-recognisers maps to this face. The adr-0087: not-required (no-migration-prescription) disposition is correct: no stored row shape changes, nothing for objectstack migrate meta to rewrite. Metadata-protocol: none beside a (narrowing) arm is not one of the gate's two contradictions.
  • The narrowing is published honestly: the changeset's Before / Now / Unchanged / Who sees it / Remedy lines match the code read above, line for line.

Clause-②: no (narrowing) — measured on this diff: diffMetaItem, and GET /api/v1/meta/:type/:name/diff through it, refuse a named from/to version with no history row of the item with 404 VERSION_NOT_FOUND where they answered 200; nothing is widened, no export, error code or spec key is added.

③ Boundary flags

  • Card "Not confirmed: whether a version from ANOTHER item's history leaks through" — answered, no. Judgment 6 above: the read is keyed by (type, name); the leak was never possible, the miss read as "everything removed", and it is now the 404, pinned.
  • Dev report open_questions: [] — nothing to answer.
  • Dev out-of-scope finding: execution-duties.md line 106 says a narrowing card is Clause-②: no, while the claim (6434783381) and the dev report said yes (narrowing) — answered, the head is right. The claim's yes (narrowing) is the pre-ruling-A spelling; the head's no (narrowing) on both carriers agrees with execution-duties line 106, with clause2-line.mjs's definition of the arm, with the changeset gate's reading (the arm, not the value, carries the breaking signal) and with the face this record is rendered on. The dev moved the line in db2eb1e5 before the four changeset gates re-ran green. The process-text drift the dev noted (precedent changesets spelled yes (narrowing)) is real but is not this PR's; it stays noted, not escalated.
  • PR "Acceptance notes": from=0 was a test idiom only — confirmed by the caller grep in judgment 7; step 6 probed as a view, not the card's dashboard — accepted: both reach the same diffMetaItem lines and both gate on their own key at the REST door, and the protocol-level test uses dashboard.
  • Dev flag: check-plugin-teardown-shape.mjs --self-test exit 3 locally (fixture commit unreachable in a shallow clone) — CI owns it; Lint · scoped · tooling self-tests is success on this head.
  • File surface beyond the claim (sys-metadata-repository.ts, scripts/engine-double-contract.pinned.json) — accepted: the first is the shared constructor the ruling asked to reuse rather than mint twice, the second is the ledger the stub-engine gate requires; neither is packages/spec/**.

Check-runs on db2eb1e5 at 2026-10-27T07:25Z: success — Check Changeset (both runs), Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, Dogfood Regression Gate, Plan Test Core shards, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI release & engine gates, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Type Check · source gates, Type Check · debt ledger, the four claim/issue/path guards, Auto Label, filter. Still running: Build Core, Test Core (1/8–8/8), Type Check · workspace, Type Check · consumer gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus. None has failed. This verdict is the contract reading on the head; the running families conclude on their own in the merge group.

Implemented-by: claude/project-thread-91nyz6
Reviewed-by: fable subagent of session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T08:11Z

Head db2eb1e596, report 6436042712 (on #26462), claim 6434783381, contract review 6436224605 (CONTRACT_REVIEW_TIER, narrowing face, PASS on this head), dogfood addendum 6436845057.

item reading
Shape Draft, base main, Fixes #26462, with a ! title. 8 files in metadata-protocol and rest tests, plus scripts/engine-double-contract.pinned.json. One patch changeset for @objectstack/metadata-protocol, with a BREAKING banner and ADR-0087 not-required (no-migration-prescription). Clause-②: no (narrowing) and Metadata-protocol: none appear in both the body and the changeset.
Fix Triage 6434115388, ADR-0110 D3. diffMetaItem refuses a named from or to with no history row of (type, name) with 404 VERSION_NOT_FOUND. It reuses versionNotFoundError, now shared with the rollback door (the same sentence, byte for byte). Tombstones are still served. The default range, the item-with-no-history default answer and the 503 outage path are unchanged.
Contract Review 6436224605: no export, error code or spec key is added. The REST door narrows through the unchanged handleRouteError, and each rewritten pin keeps its subject.
Evidence CI on head db2eb1e596: 41 succeeded, 13 expected skips, none failed or pending; mergeable clean. The full dogfood battery on this head is in 6436845057: VERDICT exit 0, 300 files and 2532 tests passed, 3 and 18 skipped, no failures (review-checklist.md :50).
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 08:12
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 27, 2026 08:12
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 1c63231 Oct 27, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-91nyz6 branch October 27, 2026 08:33
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/m tests tooling

Projects

None yet

2 participants