Uh oh!
There was an error while loading. Please reload this page.
Inventory the DESTRUCTIVE_CHANGE 409's wire faces, and pin the sole carrier that forbids the trim - #11016
Conversation
… and pin the sole carrier #10886. `saveMetaItem`'s Phase 3a-destructive 409 renders its findings into the message AND attaches the same array as `err.issues`, so a console that renders both channels shows every finding twice — the render-then-attach shape #10524 trimmed on the publish refusals. The card's own first step is the face inventory, and the inventory says DO NOT TRIM. Enumerated from every caller of `saveMetaItem` in the repo, then filtered by the gate's own predicate (`!force`, folded type `object`/`field`, an existing item under the target name, a non-empty diff), four of the seven callers cannot reach the gate at all — three pass a literal non-object `type`, one passes `force: true`. Of the three that can: - the two `@objectstack/rest` `PUT /meta` doors and the `@objectstack/runtime` dispatcher door all resolve through `resolveThrownHttpError`, so `issues` reaches the wire structurally (a top-level `issues` on the REST body, `details.issues` on the dispatcher envelope) — a message trim would lose nothing there; - `duplicatePackage`'s `failed[].error` is a SOLE CARRIER. It reports per-item failures as DATA on a 200 (`POST /packages/:id/duplicate`), so no HTTP boundary is involved and `details.issues` never exists; the array is typed inline as `{ type, name, error }`; and unlike `publishPackageDrafts` — whose `failed[]` #10895 could extend because it has a response schema — `duplicatePackage` has none in `packages/spec` at all. So the declare half of declare-then-trim is not done for this refusal, and the trim is refused. Declaring a channel on that face is a `packages/spec` change and is deliberately out of this card's scope; reported to the PM instead. Reaching that face was measured, not argued, and the obvious attempt misleads: a duplicate re-namespaces objects, so the target name usually does not exist, the gate is skipped, and the copy fails the author-time gate instead. The gate fires on the duplicate-again workflow, where the target namespace already holds the renamed object. No behaviour change: one new pin file plus a comment recording the verdict at the throw site, so the next author is told which measurement they are standing on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 1 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 — 7 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 fcf4a2ddca22948ae22fa616837db5498d32d485 && git checkout fcf4a2ddca22948ae22fa616837db5498d32d485
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5359a9b4c67c737b8319e8b08e44561aaed4dd1a 25c318d94498df6c976f78745aa97e09b9a93a1e && git checkout -B drift-repro 5359a9b4c67c737b8319e8b08e44561aaed4dd1a && git merge --no-ff 25c318d94498df6c976f78745aa97e09b9a93a1e
node scripts/docs-audit/affected-docs.mjs --json 5359a9b4c67c737b8319e8b08e44561aaed4dd1a
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32556242229 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10886
The card's own first step is the face inventory, and it binds: which faces quote this 409's message, and which of them carry
issuesstructurally. The inventory is done, and its answer is DO NOT TRIM — the same verdict, reached the same way, as the siblingINVALID_METADATAmessage that was trial-trimmed during #10524 and reverted.No behaviour changes. One new pin file, plus a comment recording the verdict at the throw site so the next author is told which measurement they are standing on.
The duplication is real
saveMetaItemPhase 3a-destructive (packages/metadata-protocol/src/protocol.ts, the join now at:13169— the card's:13131-13141is stale, #10895 moved it) renders its findings into the message and attaches the same array aserr.issues. Measured through the real producer:A console rendering both channels shows every finding twice.
The inventory, and how it was enumerated
The 409 is raised in exactly one place, so a face is any place a caller's catch puts the thrown value's
.messageonto a response. The enumeration is therefore mechanical: every caller ofsaveMetaItemin the repo (grep -rn "saveMetaItem(" --include=*.ts --include=*.tsx --include=*.mts, minus prose, tests anddist— seven call sites; a.js/.mjs/.cjssweep adds onlycheck-durability-degradation-log-level.mjs's own self-test fixtures), then for each: can it reach the gate, and if so what does its catch emit.The gate fires only when ALL of
!request.force, folded typeobjectorfield, an item already exists under the target name, and the diff is non-empty. That predicate eliminates four of the seven outright.forceissuesstructurallyrest/src/rest-server.ts:5472—PUT /meta/:type/:name?forcehandleRouteError409 bodyissuesrest/src/rest-server.ts:6590—PUT /meta/:type/:a/:bhandleRouteErrorbodyruntime/src/domains/meta.ts:417— dispatcherPUT /metaerrorFromThrowntodetails.issuesruntime/src/domains/packages.ts:517— ADR-0045 visibility flip'app'unhideError)protocol.ts:14145—migrateStoredMetadataforce: truerows[].reason)protocol.ts:16807—duplicatePackagerow.typeincl.objectfailed[].erroron a 200plugin-security/src/permission-set-projection.ts(x4)'permission'Rows 4, 5 and 7 are eliminated by a constant in the call itself — a literal
type, orforce: true— so there is no runtime state that could make them reach the gate. Rows 1-3 and 6 are pinned in the new test file.The plugin REST API catalog (
packages/spec/src/api/plugin-rest-api.zod.ts:816) declaressaveMetaItemas a handler, but it is a declaration rather than a dispatcher: the routes it describes are rows 1-3, already counted.Rows 1-3 do not block a trim
All three resolve through the one shared rule,
resolveThrownHttpError(@objectstack/types). Measured:error(the message text),code: 'DESTRUCTIVE_CHANGE', and a top-levelissuesarray.resolveErrorResponse's 4xx arm readserror.issuesstraight onto the body.details.issueson theApiErrorSchemaenvelope.A message trim loses nothing on any of them.
Row 6 is why the trim is refused
duplicatePackagereports per-item failures as response DATA on a 200 (POST /packages/:id/duplicate), so no HTTP boundary is involved anddetails.issuesnever exists. Three facts, each independently sufficient:Array<{ type: string; name: string; error: string }>(protocol.ts:16691) — noissuesslot;protocol.ts:16838) adds noissueskey;publishPackageDrafts, whosefailed[]Publish refusals: declare failed[].issues + seedApplied.issues, then trim error to a headline #10895 could extend becausePublishPackageDraftsResponseSchemaexists,duplicatePackagehas no response schema inpackages/specat all (grep -rn "DuplicatePackage" packages/spec/srcreturns nothing).So the declare half of declare-then-trim is not done for this refusal, and trimming would delete the prescription from that wire.
Reaching row 6 was measured, not argued, and the obvious first attempt says the wrong thing: a duplicate re-namespaces every object (
com.acme.crmtocom.acme.crm2mapscrm_tasktocrm2_task), so the target name usually does not exist,previs null, and the gate is skipped — the copy fails the author-time gate instead. The gate fires on the ordinary duplicate-again workflow, where the target namespace already holds the renamed object:Expected NON-effects, stated before each run and confirmed
?force=trueremedy is still present on the message — pinned twice, once on the throw and once on row 6's face, because it is a risk-acknowledgement flow and no structured channel on any face carries the remedy;INVALID_METADATAgate is unchanged;packages/specuntouched. A declaration is what row 6 needs, and per the dispatch brief that is reported rather than widened into here. It is already scoped by saveMetaItem's spec-validation 422 message restates itsissues[]on the envelope, but message-only faces (duplicatePackagefailed[].error) forbid the #10524 trim until they declare a structured channel #10888.Reverse verification — direction predicted before running
Predicted with the message trimmed to a headline (count plus field locators, remedy kept — the trim this card declines): the two prose assertions go RED, everything else GREEN, because the structured channel is untouched by a message trim. Predicted 2 red / 5 green. Measured exactly that, and the two red cases were the two predicted:
src/distin both directions: the tests import./protocol.js— a relative source specifier — andpackages/metadata-protocolships no vitest config and no alias table, so vitest resolves the subject tosrc/protocol.tsand nodist/is on the path. The mutation therefore needed no rebuild, and its RED result is what rules out the stale-artifact false green (a mutation that never reached the subject stays green). Restoration proved on disk:git hash-objectd1a54eca01c78e96360ab002172475bee5770956equalsgit rev-parse HEAD:packages/metadata-protocol/src/protocol.ts,git diff --exit-code0, porcelain empty, ablation marker absent (grep -c0). The restore leg then re-ran green 7/7.Tests and gates — all at
25c318d944, the final commitmetadata-protocoldeclares notypecheckscript (it carries a DEBT ledger entry instead), so its type coverage is the ratchet's, run below.Gate union from
node scripts/pm/dispatch-gates.mjswith no path arguments, after the final commit — 10 path-matched plus the 5 it names as convention-triggered by adding a test file. Exits captured before any pipe. All green:Quoting the gates' own verdict lines rather than a bare exit code:
check:type-check-debtis the ratchet half and refuses to run on an unbuilt worktree, so the workspace closure was built first exactly aslint.ymldoes (turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful) before the--re-measureabove. No baseline or debt ledger moved in either direction —git statusafter the run named only the two files in this diff.One gate result is worth recording because the first run was RED.
check:engine-double-contractasked for the new test's fake engine to be added to the pinned ledger. Rather than growing the ledger, the double'supdate/deletewere removed: every case in this file drives a REFUSAL, so nothing is ever persisted and those verbs were never called — verified by removing them and re-running 7/7 green. A comment in their place states that adding a persisting case means adding them back throughassertEngineUpdateDispatch/assertEngineDeleteDispatch. The gate then passed with no ledger change.Changeset
None, deliberately. The diff is one new test file plus a comment block; nothing user-visible ships and no package's behaviour changes. Requesting
skip-changeset.Out-of-scope findings, filed unassigned rather than fixed here
fieldlimb that can never produce a finding — two independent reasons #11014 (finding) — the gate'sfieldlimb is inert for two independent reasons:detectDestructiveObjectChangesreads afieldsmap aFieldSchemabody does not carry, andfieldis declaredallowRuntimeCreate: falsesosaveMetaItemrefuses it withNOT_CREATABLEbefore persistence (measured).POST /packages/:id/duplicatereports "re-submit with ?force=true to proceed" on a door that accepts noforce— the remedy names a mechanism unavailable on that face #11015 — the?force=trueremedy is quoted ontoduplicatePackage'sfailed[].error, a door whose request accepts noforceat all, so the sentence is unactionable on that face.Generated by Claude Code