fix(metadata-protocol): render the spec-validation 422 findings clause per write face (#10888) - #11120
Conversation
…e per write face (#10888) `saveMetaItem`'s `422 INVALID_METADATA` restated its own findings in the message (`<path>: <message>` for the first three, plus a `(+N more)` tail) while attaching the same array as `err.issues`. On the HTTP 422 both channels ride one response, so every console rendering both showed each finding twice. The clause is now rendered per face, reusing the `writeFace` channel #11015 established for the sibling 409. The `/meta` HTTP write doors — REST's `PUT /meta/:type/:name` and `PUT /meta/:type/:a/:b`, and the runtime dispatcher's `PUT /meta` — declare `writeFace: 'meta-envelope'` and get the #10524 headline: issue count plus up to three `path [zod code]` locators, the same grammar `seedRequestValidationError` and the author-time gate compose. `err.issues` is attached unchanged on every face. Silence keeps the full prose. The inventory re-derived for THIS gate finds four message-only faces, not one: `duplicatePackage`'s `failed[].error`, `migrateStoredMetadata`'s `rows[].reason`, and two out-of-package log faces that the 409's table eliminates by a constant and this gate cannot — `force` does not exempt a caller here, and `app` and `permission` both have registered schemas. Two of them live in other packages and could not declare a face without making the field caller-settable, so the destructive direction requires an explicit declaration and can never happen by omission. #10886's verdict is untouched: the duplicate face keeps the prescription in full, and its three #8333 GUARD pins stay green unmodified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 3 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 32 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 03fbee70fd0dca830fc8d6dd4aaec648f6ba5c00 && git checkout 03fbee70fd0dca830fc8d6dd4aaec648f6ba5c00
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5337ef162dbbb5c24125045ee6ef7375fb984966 9b2dc276d3f378b1aadf86b078cc1abd049934d6 && git checkout -B drift-repro 5337ef162dbbb5c24125045ee6ef7375fb984966 && git merge --no-ff 9b2dc276d3f378b1aadf86b078cc1abd049934d6
node scripts/docs-audit/affected-docs.mjs --json 5337ef162dbbb5c24125045ee6ef7375fb984966
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32592880370 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes #10888
saveMetaItem's spec-validation422 INVALID_METADATArendered its own findings into the message — the first three issues, each as its path, a colon, and its message, plus a(+N more)tail — and attached the same array aserr.issues. On the HTTP 422 both channels ride one response, so every console rendering both showed each finding twice: the #10524 shape, on the save door.Implements the maintainer ruling on #11017 (2026-08-22, option D). Quoted verbatim, untranslated:
Zero
packages/specchange. Option A (declaring a response contract forduplicatePackage) is not taken here.The ruling's premise, measured before any edit
The ruling marked its own load-bearing assumption unmeasured: that the 422 refusal flows through the same face-declaration channel #11099 built. It does. Re-derived on
origin/main@147f9cd29(the card body's~:13306is stale):saveMetaItem's bodyzodIssuesToMetadataIssues(parsed.error.issues)at:13443;saveMetaItemspans:12908–:13996(next methodmigrateStoredMetadataat:13997)request.writeFaceis in scope at the 422 site:12908duplicatePackage's catch at:16925records that the spec rejection is422 INVALID_METADATAand that the whole "Unrecognized key(s) on this view container …defineView({ … })" prescription comes through unchangedThe inventory had to be re-derived — the 409's table is wrong here in three rows
This is the part that shaped the design. The 409 fires only when
!request.forceand the folded type isobject/fieldand a row exists and the diff is non-empty; that predicate eliminates four of its seven callers. This gate has none of those conditions — it fires whenevergetMetadataTypeSchemaresolves a schema for the folded singular type and the body failssafeParse.issues[]restPUT /meta/:type/:namesendError422 bodyerror-response.ts:1454threads a top-levelissuesrestPUT /meta/:type/:a/:bruntimedispatcherPUT /metaerrorFromThrown→details.issueshttp-dispatcher.ts:732, pinned athttp-dispatcher.test.ts:223runtimeADR-0045 visibility flipappapp: AppSchemais registeredunhideError+ logmigrateStoredMetadataforce: truedoes not gate this checkrows[].reasonduplicatePackagerow.typefailed[].erroron a 200plugin-securityprojection ×4permissionpermission: PermissionSetSchemalogger.errortextRows 4, 5 and 7 are the ones the 409's inventory eliminates by a constant and this one cannot. Row 5's own catch comment states the dependency outright ("each is still quoted verbatim and still names the fix"); row 7's log prescribes "make the record body spec-valid (the error names the offending key)" — a remedy written on the assumption that this clause names it.
The ruling's principle holds unchanged, but the "keep full text" side has four members rather than one.
Consequence for the design: silence keeps the prose
Rows 4 and 7 live in other packages, reached through
(protocol as any). Neither can state a face without makingwriteFacea field an arbitrary caller sets. Under the opposite polarity — trim by default, message-only faces opt out — both lose their prescription silently, and so does every write door added later by an author who never read the comment.So the polarity is declare-to-trim: an absent or unrecognised face renders the full prose byte-identically, and only rows 1–3 declare
writeFace: 'meta-envelope'. The destructive direction requires an explicit, reviewable declaration at a call site and can never happen by omission.Not client-settable, structurally: each door builds the request object field by field from named
reqvalues and never spreads the body.GUARD pins: all three green, unmodified
The card named three pins that went red when a blanket trim was tried. Each was checked for which face it protects:
protocol.batch-verb-driver-text.test.tsP10duplicatePackageprotocol.save-union-issues.test.ts:140saveMetaItemcall with no face (rows 4–7)protocol.save-union-issues.test.ts:262No pin was moved, and none was made green by deleting what it protects. That all three sit on the keep side is the strongest evidence the polarity is right.
Reverse verification — direction predicted before running
Predicted: deleting
specValidationFindings''meta-envelope'case (so every face falls to the prose branch) turns the'meta-envelope'pins RED and moves nothing else, because the three GUARD pins assert the default face.Mutation proven on disk before measuring — anchored greps on both the injected and the deleted text, not an editor exit code:
Exactly the predicted direction and scope. The subject is imported as
./protocol.js— a relative source specifier — so vitest resolves it tosrc/protocol.ts, nodist/on the path, and the ablation needs no rebuild; the restore leg is proven on disk too, and the mutation script carried atrap … EXIT INT TERM.The headline's worst input, measured (the card's open question)
The card asked whether a trimmed headline degrades unacceptably on root-level union failures, where every locator's path is empty and the key names live only in
issues[]. Its worry was that the locators would collapse to nothing but a bare[invalid_union]; [unrecognized_keys]with empty space where the paths should be.Measured: they do not render blank. An empty path falls back to the ⟨root⟩ placeholder, so the locator reads
⟨root⟩ [invalid_union]; ⟨root⟩ [unrecognized_keys]. That fallback predates this card and survives it. Degraded but not lossy: by the definition of the face,issues[]rides along carrying the key names, and the pin asserts both halves — including the absence of the blank-locator shape.Hoisting key names into the locator would change
metadataIssueHeadline, which the seed refusal and the author-time gate also compose — three doors' grammar. Deliberately not done here; per the dispatch that is an improvement, not a requirement.Changed files
packages/metadata-protocol/src/protocol.ts— newspecValidationFindingsface switch;MetadataWriteFaceextracted so the 409 and 422 switches share one vocabulary; the 422 site; the stale "deliberately NOT trimmed" comment replaced.packages/rest/src/rest-server.ts,packages/runtime/src/domains/meta.ts— rows 1–3 declare the face.packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts— new, the inventory and its pins (8 tests).packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts— a guard that a face declared for the 422 does not move the 409 remedy. Without it, folding the two switches together would change a 409 remedy on the busiest write door while every 422 test stayed green.packages/runtime/src/http-dispatcher.test.ts— two exact-match call-shape pins now record the server-stated face.packages/rest/src/rest-4xx-message-truncation.test.ts— fixture triage, no assertion changed: its docblock claimed its constructed long message is what this route receives. That is no longer true for the/metadoor, so the docblock now says the long form belongs to the message-only faces and that the subject under test issendError's bound, not the metadata door's wording.scripts/engine-double-contract.pinned.json— the new test file's engine double, recorded as pinned coverage (--write); the shrink-only baseline is unchanged at 66/100.Deviation from the dispatched file surface, declared
The dispatch expected
protocol.ts+ tests. Rows 1–3 are inrestandruntime, and touching them is forced by the measurement: insideprotocol.tsevery caller ofsaveMetaItemis a message-only face, so a server-stated-only mechanism could mark only the keep side — which forces the unsafe polarity. No contract surface widens: spec'sSaveMetaItemRequestSchemadeclares{type, name, item}only, andforce/mode/packageId/source/parentVersion/writeFaceare all already undeclared internal fields.Verification — all on
9b2dc276dpnpm --filter @objectstack/metadata-protocol test— 1853 passed | 2 skipped (1844 before, +9 new)pnpm --filter @objectstack/rest test— 2179 passed;pnpm --filter @objectstack/runtime test— 2696 passedpnpm --filter @objectstack/rest --filter @objectstack/runtime typecheck— bothDone.metadata-protocoldeclares notypecheckscript (ledger package); its closure is covered by the build and the debt ratchet below.node scripts/pm/dispatch-gates.mjs(no paths — it reads the change set itself) and re-run on this commit:check:authz-resolver,check:changeset-gate-self-tests,check:cross-package-test-inputs,check:dispatcher-error-vocabulary,check:durability-log-level,check:filter-alias-parity,check:objectui-changeset,check:route-envelope,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:nul-bytes, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-cross-package-test-inputs,check-plugin-teardown-shape,docs-audit/check-affected-docs— all exit 0.pnpm check:type-check-debt— refused first with the unbuilt-closure error, so the closure was built (turbo run build, 70/70) and it was re-run:OK — 33 ledger entr(ies) re-measured in 355.3s, 1908 raw tsc error(s) total, none above its recorded number.pnpm lintis CI's run; delivered here as a measured narrowing —eslint --no-inline-config --format jsonover the 7 changed files, 7 files linted, 0 errors, 0 warnings. The narrowing excludes nothing:eslint.config.mjsnever enables type-aware linting for any file (:328, "noparserOptions.project, no typed@typescript-eslintrules", verified there with a positive control), so this diff cannot move the verdict on any untouched file.Generated by Claude Code