Skip to content

fix(metadata-protocol): assert the canonical type at the SchemaRegistry overlay mint door - #9173

Open
os-zhuang wants to merge 9 commits into
mainfrom
claude/issue-9111-hydrate-overlay-raw-type
Open

fix(metadata-protocol): assert the canonical type at the SchemaRegistry overlay mint door#9173
os-zhuang wants to merge 9 commits into
mainfrom
claude/issue-9111-hydrate-overlay-raw-type

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Fixes#9111

hydrateOverlayIntoRegistry — the one choke point boot hydration, the read-side hydration and the write-through all funnel through — minted registry entries under whatever type spelling it was handed. No fold, no assertion, a bare type: string parameter, and a load-bearing "callers must fold" contract that lived in no type, no signature and no check. This was the last unfolded seam of the objects tolerance family, which has now cost four cards (#8820, #8862, #9009, #9111).

Phase 1 — the reachability measurement

Taken before writing the fix, and taken with the read side first, because #9157 had just falsified "every /meta entry point folds through canonicalizeMetaRequestType" one function over. That assumption was not inherited here.

Six producer routes into the helper:

#ProducerFoldVerdict
1getMetaItems (read-side hydration)canonicalizeMetaRequestTypecomplete
2saveMetaItem to write-throughcanonicalizeMetaRequestTypecomplete
3rollbackMetaItem to write-throughcanonicalizeMetaRequestTypecomplete
4promoteDraftForPublish to write-throughPLURAL_TO_SINGULARcovered — publishMetaItem folds at the boundary, and publishPackageDrafts is pre-empted by #8908's STORED_TYPE_NOT_CANONICAL pre-flight
5revertCommit to write-throughPLURAL_TO_SINGULAR[it.type] over a stored commit-item typeunguarded
6loadMetaFromDb (boot)PLURAL_TO_SINGULAR[record.type] over a stored row typeunguarded

Routes 5 and 6 fold through the manifest-collection map — tolerant AND incomplete, exactly the trap PR #9161 named one seam over. It resolves the plurals that were never the hazard and passes through the spellings whose types are not stack collections, for which the fold is a no-op and the raw spelling reaches registerItem. The class is derived rather than hand-listed (#8908 measured that a hand-written "four types" list ships two members short): fields, seeds, external_catalogs, externalCatalogs, translations, email_templates.

Task, not Bug — and that is a measurement, not a grading inherited from the card. The only sys_metadata writer that stamps a caller-chosen type is saveMetaItem's repo.put, which folds at the boundary, so no live write can mint such a row. The population that reaches routes 5 and 6 is pre-#7894 at-rest residue, which is real (PUT /meta/fields/... answered 200 and persisted before #7894 closed that door) and which nothing rewrites on upgrade. No live unfolded caller exists, so this stayed a Task.

⛔ Not a claim that #8862's removal introduced a defect. The seam predates it and is unchanged by it.

Phase 2 — the enforcement

hydrateOverlayIntoRegistry now asserts its type is canonical and refuses with REGISTRY_TYPE_NOT_CANONICAL (status 500, the ADR-0112 envelope) when it is not. The assert is placed ahead of every no-op return, so a caller that stops folding cannot hide behind an org-scoped or nameless body.

Deliberately an assert and not a fold, which is the half that decided the shape. Folding here would be the tolerant lookup below a folding boundary that canonicalMetaType's header has rejected since #4432 — and here it would do something strictly worse than dilute a contract. A pre-#7894 row exists becausePUT /meta/fields/... slipped past the lock that answers PUT /meta/field/... with 403 NOT_OVERRIDABLE. Folding it into the canonical key at boot would honour, process-wide, precisely the override #7894 closed the door on — laundering a row through the hole that created it. Refusing leaves the row exactly as unreachable as it already is, and says so out loud.

Loudness per route, since neither unguarded route is a /meta request with a caller to answer to: at boot the throw is caught by loadMetaFromDb's per-record handler, so the row is counted in errors and named in a warning, and the rows around it still land; on the write-through it degrades to applyRegistryWriteThrough's best-effort console.warn, which is correct there and not a softened assert — the row is already persisted, and #4521's contract is that a registry hiccup must never fail a committed write.

⚠️No audit or ledger key is touched. The assert is scoped to the registry mint door — registerItem and lookupArtifactItem only — and no producer's spelling is changed by it, so no audit row, commit record, receipt or repository key moves. #8908's AUDIT_TYPE_NOT_CANONICAL ruling is untouched in both directions.

Two now-false comments about this seam are corrected rather than left contradicting the code: the [#8862] paragraph in applyRegistryWriteThrough and the matching note in protocol.object-registry-write-through-spelling.test.ts. Both are kept in the past tense, because they are what this card was filed about.

Changed line ranges

  • packages/metadata-protocol/src/protocol.ts — post-merge line numbers from git diff --unified=0, unchanged by both merges: 11398-11470 (the hydrateOverlayIntoRegistry TSDoc carrying the measurement), 11477-11496 (the assert itself), 11650-11666 (the [#8862] paragraph in applyRegistryWriteThrough corrected to past tense). No other hunk in this file.
  • packages/spec/src/api/error-code-ledger.zod.ts — one line added
  • packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts — new
  • packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts — header note only
  • content/docs/references/api/*.mdx — regenerated (gen:docs); the whole diff is the two roll-up counts each moving by exactly one, plus the one ledger row

Merges with main (two rounds; current head b3f699308)

This PR regenerates 11 shared content/docs/references/api/*.mdx pages plus the error-code ledger, which every error-code PR also touches, so it needs to stay close to main to remain landable.

Round 1 — base 66beee0f3 to 09a9a8ae0 (6 PRs). One hand-written conflict in error-code-ledger.zod.ts, where both intents stacked: #9170 added QUERY_OBJECT_MISMATCH (#8846), this PR adds REGISTRY_TYPE_NOT_CANONICAL. Kept both, alphabetically. Merge eb128ed45, regen f5a736f52.

Round 2 — base 09a9a8ae0 to 671d4736f (12 PRs, including #9177's nine new REST wire codes). The ledger auto-merged this time. Merge b4c842c1d, regen b3f699308.

Both rounds ran scripts/pm/os-regen-merge.sh rather than a hand merge, whose fixed order is the whole point:

  1. git merge origin/main
  2. every merge=os-regen path taken from main's side wholesale, path list read from .gitattributes at run time
  3. the merge committed FIRST — the pre-commit hook records it as a deferral, not a pass
  4. then wholesale gen:schema && gen:docs (+ gen:openapi, which gen:schema's cleanup wipes) as a separate commit, discharging the deferral

gen:authorable-surface-base was never run; authorable-surface.base.json is unchanged and check:authorable-surface is green regardless.

⚠️Round 2 hit the staged/unstaged trap and it is worth recording. After step 2 stages main's side and step 4 rewrites the worktree, git status shows MM — index and worktree disagree, and a bare git commit builds from the index, which would have committed main's side and silently dropped the regeneration. Caught by reading porcelain before committing; fixed with an explicit git add and verified by reading the code back out of the index (git show :content/docs/references/api/error-code-ledger.mdx).

Neighbour control — run ledger-wide, not on a handful

PR #8986 lost two error codes to a driver that exits 0 with no conflict markers. A roll-up count cannot detect that, so the check is a page-set comparison. Round 2 ran it over the whole ledger rather than a few neighbours, since main has taken several error-code PRs since:

241 ERROR_CODE_LEDGER codes scanned across 213 reference pages
0 codes with an EMPTY page-set <- nothing dropped
225 share the baseline page-set contract.mdx + error-code-ledger.mdx
(REGISTRY_TYPE_NOT_CANONICAL among them)
16 outliers, every one a SUPERSET of that pair (also named in domain prose:
errors.mdx, batch.mdx, protocol.mdx, dispatcher.mdx, view.mdx, ...)

No code appears on one of the pair while missing the other, and none is absent entirely — the #8986 signature is excluded ledger-wide, in both directions.

Roll-ups corroborate rather than carry it: main 1x +281 / 66x +285, this branch 1x +282 / 66x +286 — both moved by exactly one, for the one code added. The two distinct counts are pre-existing structure on main (one page enumerates 7 codes inline, the rest 6), not drift.

Verification — re-run at b3f699308

The merge brought commits touching spec, metadata-protocol and objectql — the same packages this diff touches — and #9186 made ObjectStackDefinitionSchema strict, so the full re-run was owed rather than a scoped one. After pnpm install --frozen-lockfile, both closures rebuilt, rm -rf packages/runtime/.objectstack:

@objectstack/spec 407 files / 10827 tests passed
@objectstack/metadata-protocol 116 files / 1599 tests passed
@objectstack/objectql 213 files / 3755 tests passed

Counts grew because the merges brought sibling PRs' own tests, not because anything here changed shape. typecheck green for both packages. check:generated reports all 13 generated artifacts up to date at the head — the ratchet family included, which is what matters most after an os-regen merge.

objectql matters specifically: it owns protocol-org-overlay-registry-gate.test.ts and protocol-boot-hydration-scoped.test.ts, the two files that drive this seam directly. The consumer sweep is the downstream direction (--filter '...@objectstack/metadata-protocol', prefix form).

Gate union re-derived against the merged path set — and the derivation itself changed under us: #9188 (derive dispatch gates from CI's own path triggers) landed inside this very merge, and now finds 24 families where the pre-merge script found 20. The four it adds are real and were run: check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs — all pass. That is the #9191 gap closing itself, observed rather than assumed.

All 24 re-run at b3f699308 and passing, plus the convention-triggered check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage.

Unchanged local-only failure: check-dev-prereqs ("the workspace is not built — 54 of 67 packages declare an entry point under dist/ that is not on disk"), because this worktree builds only the closures it needs. CI checks out fresh and builds.

Ablations — predicted before running, reported as observed

Run pre-merge at 77cbb5dd9; neither merge changed a line of the asserted code, and the new test file passes unchanged at the current head.

  1. Ship state — predicted GREEN, got GREEN (11/11).
  2. Assert deleted — predicted RED, got RED (6 failed). This leg is the pre-fix measurement, and it printed the defect rather than merely failing: registerItem received [ { type: 'fields' } ] and [ 'view', 'translations', 'view' ], the raw stored spelling minting a shadow namespace, with boot reporting errors: 0.
  3. Assert weakened to isNonCanonicalStoredType — predicted RED in the second section only, got exactly that (1 failed): the manifest-present plural 'objects' stops being refused while the manifest-absent six still are. This is what proves the complete assert is doing work the narrow at-rest predicate does not.
  4. canonicalMetaUrlType neutered to the identity, spec rebuilt — predicted RED in the first section, got RED broader than predicted (7 failed). The prediction was the wrong shape: the first section does go red first, but the assert sections go red with it, because canonicalMetaType delegates to canonicalMetaUrlType. Recorded as observed rather than trimmed to the prediction. Tests resolve @objectstack/spec from its built dist/, so this leg ran mutate to rebuild to prove-in-artifact; the marker comment was stripped by the bundler, so the artifact proof is behavioural — canonicalMetaUrlType('objects') returned 'objects' from dist/ under the ablation and 'object' again after the restore, with .build-input-hash back to its pre-ablation value.

Reverse verification was run from the committed state throughout; git hash-object confirmed byte identity on restore.


Generated by Claude Code

…t door (#9111)
`hydrateOverlayIntoRegistry` minted registry entries under the raw `type`
with no fold and no assertion — the last unfolded seam of the `objects`
tolerance family. The "callers must fold" contract lived in no type, no
signature and no check.
Measured across all six producer routes first: four fold completely, and two
(boot `loadMetaFromDb`, `revertCommit`) fold through the MANIFEST-collection
map, which is tolerant AND incomplete — it passes through the six spellings
whose types are not stack collections. Dormant for live traffic; reachable
only from pre-#7894 at-rest residue.
Closed as an ASSERT rather than a fold: folding would launder a row past the
NOT_OVERRIDABLE lock that #7894 closed the door on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…rved ablation directions (#9111)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…engine double
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@github-actions

github-actionsBot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 4 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol))
  • content/docs/concepts/metadata-lifecycle.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol))

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

  • content/docs/releases/v16.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol), ObjectStackProtocolImplementation (symbol))

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

Coarse fallback — 113 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 origin/mainpackageMentionDocs.

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 origin/main → pass the list as args.docs.

…rate-overlay-raw-type
# Conflicts:
#	packages/spec/src/api/error-code-ledger.zod.ts
Discharges the os-regen deferral recorded against the merge commit. Wholesale
gen:schema + gen:docs on the committed merge, never hand-resolved: both
roll-ups moved by exactly +1 (1x +272->+273, 66x +276->+277) and the neighbour
control confirms #8846's QUERY_OBJECT_MISMATCH survived alongside
REGISTRY_TYPE_NOT_CANONICAL on both enumerating pages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@os-zhuang
os-zhuang disabled auto-merge August 16, 2026 21:23
@os-zhuang
os-zhuang enabled auto-merge August 16, 2026 21:23
@os-zhuang
os-zhuang marked this pull request as draft August 16, 2026 21:24
auto-merge was automatically disabled August 16, 2026 21:24

Pull request was converted to draft

@os-zhuang
os-zhuang marked this pull request as ready for review August 16, 2026 21:24
@os-zhuang
os-zhuang enabled auto-merge August 16, 2026 21:24
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

Cross-seat diagnosis (skills seat, session session_017TNzEetykdh7ceZGwuAPLq; the maintainer relayed this PR's queue-refusal report): the queue is refusing this PR because it is in merge conflict with current mainmergeable_state reads dirty right now. Zero enqueued events is the queue's correct behavior for a dirty PR, not a queue malfunction.

Your three readings were all true and all insufficient: the 28 green checks speak to this branch alone; the clean local trial-merge was against the main you fetched, which has since moved (the error-code-ledger.zod.ts you already hand-resolved once is the likely re-conflict — more error-code PRs landed after your merge commit); and mergeable_state, the one field that answers "will the queue take it", was not in the checklist. Note the field is lazily computed — a first GET may say unknown; re-poll once for the real value.

Remedy: repeat exactly what your PR body already documents — git merge origin/main per scripts/pm/os-regen-merge.sh's fixed order (merge commit first, then wholesale regen, ledger conflict resolved semantically keeping both codes), re-run the neighbour control, push — then re-arm auto-merge and confirm the enqueued timeline event. Your refusal to bypass the queue was the right call.

A skill card is being filed to codify both readings (pre-enqueue mergeable_state check + the zero-enqueued-events triage ladder) into the landing references, so this costs no one else a stall.


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

条款②补偿复审 — 结论:通过(附刷新条件)

性质:本 PR 的契约增量(ledger 新码 + hydrateOverlayIntoRegistry 接受→拒绝)落在条款②射程内(维护者 2026-08-12:凡改变契约接受/拒绝行为或扩大公开面一律 claude-fable-5),实际派发档位低于要求档。派发席已自报,维护者裁定按 #8550 先例执行补偿复审:本条即该复审的公开记录。制度化修复见 #9185(入队闸门 + needs:contract-review 复审链)。

复审席:skills 席 session_017TNzEetykdh7ceZGwuAPLq,契约复审档位(claude-fable-5),非本卡派发席——资格双条件满足(过渡期代行)。

复审范围:契约行为半区全文——packages/metadata-protocol/src/protocol.ts(+109/−8)、packages/spec/src/api/error-code-ledger.zod.ts(+1)、changeset、protocol.hydrate-overlay-canonical-type.test.ts 断言清单。docs mdx 生成物未逐行审。

逐项结论

  1. ledger 行REGISTRY_TYPE_NOT_CANONICAL 注册于 @objectstack/metadata-protocol 名下(与产源包一致),字母序正确,行注含产源与语义。接受集加宽 +1,有活产源(本 PR 新增的 throw),加宽是测量过的。
  2. mint door 断言:置于一切 no-op return 之前,理由成立(契约按拼写判定,不许躲在 org-scoped/无名体后面)。拒绝而非折叠的论证是原则性的:boot 处静默折叠会全进程尊重 meta-plural-url-bypass: PUT /meta/fields/<name> walks around the whole two-tier registry gate — 4 registry types have no entry in PLURAL_TO_SINGULAR #7894 已关死的越权路径——这不是风格选择,是行为正确性。
  3. 产源测量:六路全查,routes 5/6 的漏斗成因(manifest map 对非 stack-collection 拼写 tolerant 且 incomplete)与 live 面 dormant(唯一活写入者在边界折叠)都是测量而非假设,注释与测试互相咬合。
  4. 响度:boot 走 per-record handler(计数+点名,不阻断邻行);write-through 降级 console.warn 合既有契约(已提交写不因 registry 打嗝失败)——不是软化断言。
  5. 不可误拒面:canonical 恒等、plugin/未知 kind 恒等,均有钉。
  6. 未发现缺陷。

刷新条件:本 PR 现 mergeable_state: dirty(ledger 同文件与今日已合 PR 冲突)。冲突解决后若 protocol.tserror-code-ledger.zod.ts 的 hunk 有实质变化,该 hunk 需刷新复审;仅 changeset/docs/测试文本变动不触发。满足后本复审对入队闸门放行有效。


Generated by Claude Code

Discharges the os-regen deferral recorded against merge commit b4c842c.
Wholesale gen:schema + gen:docs on the committed merge, plus gen:openapi to
restore what gen:schema's cleanup wipes. No generated file hand-resolved.
Neighbour control re-run ledger-wide rather than on a handful: all 241
ERROR_CODE_LEDGER codes scanned across 213 reference pages, zero in an empty
page-set. 225 share the contract.mdx + error-code-ledger.mdx baseline
(REGISTRY_TYPE_NOT_CANONICAL among them); every outlier is a superset that also
names the code in domain prose, never a page short. Roll-ups moved by exactly
one (1x +281->+282, 66x +285->+286).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

契约复审刷新确认(needs:contract-review 清标签)— 原结论有效

此前补偿复审(2026-08-17 00:5xZ 评论)附带刷新条件:冲突解决若实质改动 protocol.ts / error-code-ledger.zod.ts 的 hunk 则需刷新。现核验:PR body 明载两份契约文件的行位与内容两轮 merge 均未变(11398-11470/11477-11496/ledger 一行),Round 1 的 ledger 冲突以「双码并存、字母序」解决(与本席已审内容一致),mergeable_state: clean,head b3f699308刷新条件满足,原「通过」结论对入队有效,清标签后可按正常落地流程入队。


Generated by Claude Code

…h main
Discharges the os-regen deferral recorded by the merge commit. Wholesale
`gen:schema && gen:docs && gen:openapi` from the merged tree, per
scripts/pm/os-regen-merge.sh step 4.
The regenerated diff against main is now 2 pages, not 11: #9239 landed on
main since round 2 and stopped the in-shape enum marker from restating
vocabulary cardinality, so a single new error code no longer bumps a
roll-up count on nine unrelated pages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude