fix(metadata-protocol,rest,objectql): a batch/import ROW reports a unique-constraint refusal as UNIQUE_VIOLATION, the route's one wire spelling - #15363
Conversation
…ique-constraint refusal as `UNIQUE_VIOLATION`, the route's one wire spelling A driver's unique-constraint refusal reached a client under two spellings on one route: the whole-request failure on `POST /data/:object/batch` and `POST /data/:object/import` answered `UNIQUE_VIOLATION` (the standard-catalog member the published protocol docs give for the 409 constraint-violation body), while a ROW on the same route relayed the engine's registered `DUPLICATE_RECORD` verbatim. Maintainer ruling A (2026-09-03): converge on `UNIQUE_VIOLATION`; the engine's thrown identity does not move; no ledger waiver. - `metadata-protocol` `toRowApiError`: the engine's `DuplicateRecordError` envelope (registered code AND class name, the whole-request arm's own gate) maps to `UNIQUE_VIOLATION` before the verbatim registered-code limb; `httpStatus` 409 and the platform sentence are unchanged. - `rest` `toFailedResult`: the same mapping for import rows, through `isEngineDuplicateRecordEnvelope` exported from `error-response.ts`; the whole-request arm's logic is untouched (its inline gate is what the sandbox drift guard keys on), and the docblock that disclosed the fork under the #14541 review now states the converged rule. - `objectql` `DuplicateRecordError`: the docblock and `developerMessage` name the wire spelling a client branches on and the in-process spelling beside it. - Pins: the runtime real-driver row pin flips and asserts no `DUPLICATE_RECORD` anywhere in the payload; metadata-protocol section 7 drives all three loops plus two guards (a hook merely speaking the registered code, and a name-only impostor, both keep their own code); a new import-runner row pin drives both write paths with the real class and pins door-to-row agreement. - Changesets: `@objectstack/metadata-protocol` and `@objectstack/rest` minor with a BREAKING banner naming the row-level spelling that moves; `@objectstack/objectql` patch for the shipped `developerMessage` string. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2oQebDDxYKfWZusyd8GXk
📓 Docs Drift CheckThis PR changes 3 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 29 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 801b010f4cf8eb3ba4437c4e32d8ff8a9698c8e6 && git checkout 801b010f4cf8eb3ba4437c4e32d8ff8a9698c8e6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4d1354e32539eb418e35757ccbafb3dea57ba93 6633fdd196722b99df8c8b28472603aca1606dfa && git checkout -B drift-repro c4d1354e32539eb418e35757ccbafb3dea57ba93 && git merge --no-ff 6633fdd196722b99df8c8b28472603aca1606dfa
node scripts/docs-audit/affected-docs.mjs --json c4d1354e32539eb418e35757ccbafb3dea57ba93
|
|
Landing provenance — Generated by Claude Code |
Fixes #14723
Draft for the
domain:specseat's Clause ② contract review (needs:contract-reviewhung on this PR and on the card). Not to be flipped ready, queued or auto-merged by the dev.The ruling this executes (not re-decidable)
Maintainer ruling A (director relay on the card, 2026-09-03, verbatim 「同意,然后执行契约复审」), operative sentences quoted:
Not taken: B (the single-record door's code — #14389's refusal stands), C (a recorded waiver). Not folded into #14541. The engine's thrown identity is not renamed:
DuplicateRecordError.codestaysDUPLICATE_RECORDin-process; what moves is what a ROW REPORTS across the HTTP boundary, exactly as the whole-request arm aterror-response.tsalready maps the class toUNIQUE_VIOLATION. The envelope gate (registered code AND class name) is the shape #15070 ruled to keep (2026-09-04, option A); both new row derivations use that same gate.Premise re-verified at the worktree base
Base
origin/main2ed6be649(fetched 2026-09-04T12:10Z; the seat's readings were at8f404a51and all still hold):packages/metadata-protocol/src/protocol.ts:2035toRowApiError— a thrown registered code went on the row verbatim (ErrorCode.safeParse(err.code).success ? err.code : …), so aDuplicateRecordErrorrow saidDUPLICATE_RECORD.packages/rest/src/import-runner.ts:289-297toFailedResult—first?.code ?? e?.code ?? 'IMPORT_ROW_FAILED', the same relay for import rows.packages/rest/src/error-response.ts:838-848documented the split in its own words;:962is the whole-request arm mapping the class toUNIQUE_VIOLATION.packages/objectql/src/duplicate-record-error.ts:96-99developerMessagesaid "branch oncode === 'DUPLICATE_RECORD'".:282assertedDUPLICATE_RECORD;error-code-ledger.zod.tscarries no waiver (:262,:777listUNIQUE_VIOLATIONas a member).Premise holds. Producer located where the dispatch expected it:
toRowApiErroris the single row-code derivation forPOST /data/:object/batch(all three loops build their row through it), and the cross-objectPOST /api/v1/batchis atomic — it has no per-row error derivation at all; its failure is whole-request throughhandleRouteError, which already answersUNIQUE_VIOLATION.What changed
packages/metadata-protocol/src/protocol.ts—toRowApiErrormaps the engine's envelope toUNIQUE_VIOLATIONbefore the verbatim registered-code limb, via a package-localisEngineDuplicateRecordEnvelope(code AND name; the class is not imported — this package does not depend on objectql).httpStatusstill reads the declared 409;messageis still the envelope's platform sentence; the driver's text stays oncause.packages/rest/src/import-runner.ts—toFailedResultapplies the same mapping before the producer's own code is read; a field-level finding still takes precedence.packages/rest/src/error-response.ts— the docblock paragraph that disclosed the fork under the Routes exiting throughhandleRouteErroranswer the engine'sDUPLICATE_RECORDenvelope fromresolveErrorResponse's.statuspassthrough —fieldandobjectdropped,mapDataError's structured 409 arms never consulted #14541 review now states the converged rule;isEngineDuplicateRecordEnvelopeis exported (package-internal; not re-exported from the rest index) for the import runner. The whole-request arm's logic is untouched, and its gate stays spelled inline:error-response-sandbox-arm-message.test.ts§6 keys every arm ofstructuredCodeAnswerby itserror?.code === '…'literal, and the first local run reddened that drift guard when the arm called the predicate instead — restored, documented at the predicate.packages/objectql/src/duplicate-record-error.ts— the docblock anddeveloperMessagename the wire spelling a client branches on (UNIQUE_VIOLATIONon every route, whole-request and per-row alike) and the in-process spelling beside it. No issue id in the customer-facing string.DUPLICATE_RECORDanywhere in the payload (the leak assertions did not move);protocol.batch-row-http-status.test.tsgains section 7 (create / update / upsert loops reportUNIQUE_VIOLATION; two guards: a hook merely speaking the registered code, and a name-only impostor, both keep their own code; an anti-vacuity case measuring the double against the built class's own-property set); newpackages/rest/src/import-runner-unique-violation-row.test.tsdrives the REALDuplicateRecordErrorthroughrunImporton both write paths and pins door-to-row agreement (mapDataError(env).body.code === row.code)..changeset/batch-row-unique-violation-metadata-protocol.md(@objectstack/metadata-protocol,minor, BREAKING banner naming the row-level spelling that moves),.changeset/import-row-unique-violation-rest.md(@objectstack/rest,minor, BREAKING banner),.changeset/duplicate-record-error-developer-message-wire-spelling.md(@objectstack/objectql,patch— thedeveloperMessagestring ships indist). Both breaking changesets carryadr-0087: not-required (no-migration-prescription), admitted by the gate. No@objectstack/specchangeset;packages/spec/**untouched.The other metadata-protocol batch-row test files (
protocol.batch-row-driver-text,protocol.batch-verb-driver-code,protocol.batch-row-conformance,protocol.batch-verb-driver-text) assert no row's code isDUPLICATE_RECORD— measured by grep, nothing to flip; all five run green.Mechanism assumptions from the dispatch
(a) held — single derivation site, mapping keyed like the arm; the multi-object
/batchhas no row derivation. (b) held. (c) held — 409 and the platform sentence unchanged; leak pins unmoved. (d) held with a correction: only the runtime pin assertedDUPLICATE_RECORD; the three metadata-protocol files named did not, so section 7 was added rather than flipped; the objectql engine pins untouched and green. (e) held. (f) held. (g) partially:check:dispatcher-error-vocabularyran green ("0 unregistered wire code(s) hiding");check:error-code-provenancedoes not exist inpackage.jsonorscripts/at this tree. (h) held; objectql owed apatch(runtime string indist), notskip-changeset.Deviations from the claim surface
packages/rest/src/rest-duplicate-record-arm.test.ts— a 3-line comment that quoted the olddeveloperMessagesentence, refreshed so the quote is not stale. Comment-only, no assertion touched.packages/rest/src/import-runner-unique-violation-row.test.ts— the "import-runner row pin" was placed in a new file rather than inimport-runner-error-sanitize.test.ts, whose subject issanitizeRowError's dialect table.Measurements, pinned to head
6633fdd19Every command captured its exit before any pipe; verdict lines are the gate's own. Heavy runs went through
scripts/pm/os-verify-lock.sh(shared box; absolute seconds are not idle-box figures).pnpm --filter '@objectstack/rest^...' build(closure)os-verify-lock: VERDICT command-exit 0 · held the lock 259spnpm --filter '@objectstack/runtime^...' build+pnpm --filter @objectstack/runtime typecheckcheck:test-typecheck: OK — @objectstack/runtime's test layer compiles … 27 file(s) / 191 error(s) / 69 pinned signature(s) held in test-typecheck-debt.json(frozen debt, unchanged) ·VERDICT command-exit 0Test Files 5 passed (5)·Tests 80 passed (80)·VERDICT command-exit 0import-runner-unique-violation-row,import-runner*,rest-duplicate-record-arm,error-response*(11 files)Test Files 11 passed (11)·Tests 211 passed (211)·VERDICT command-exit 0batch-row-driver-text-real-driver.integration.test.ts(real SqlDriver over better-sqlite3)Test Files 1 passed (1)·Tests 3 passed (3)·VERDICT command-exit 0engine-autonumber-resync,driver-fault-redaction(untouched)Test Files 2 passed (2)·Tests 82 passed (82)·VERDICT command-exit 0pnpm --filter @objectstack/objectql --filter @objectstack/metadata-protocol typecheckVERDICT command-exit 0(metadata-protocol's tsconfig includes its tests, so section 7 is typechecked)pnpm --filter @objectstack/rest typecheckcheck:test-typecheck: OK — @objectstack/rest's test layer compiles … 0 file(s) / 0 error(s)pnpm lint(fulleslint . --no-inline-config, on head)pnpm check:doc-authoring✓ doc authoring guard: 14741 customer-facing string(s) across 730 spec sources cleanpnpm check:nul-bytes✓ check-nul-bytes --self-test: 75 assertionspnpm check:dispatcher-error-vocabulary0 unregistered wire code(s) hidingpnpm check:error-code-casing✓ no unlisted lowercase error codes in 5390 scanned file(s)pnpm check:error-status-conformance✓ every derivable runtime status is documented, and every documented status is reachablenode scripts/check-system-context-census.mjsOK — 106 elevation read sites in 20 packages across 45 files, all anchorednode scripts/check-changeset-no-major.mjs --base origin/main✓ This diff introduces no major bumpnode scripts/check-adr-0087-registration.mjs --base origin/mainADR-0087 exemption (no-migration-prescription)admitted for both breaking changesetsnode scripts/check-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diff (3 declaring changeset(s) added)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands6633fdd19, 11 paths vs merge base2ed6be649; all runnode scripts/check-engine-split-ratio.mjs --days 90git fetch --shallow-since=2026-05-30): horizon 2026-05-30 covers the windowpnpm check:type-check-debtdist"would silently measure a DIFFERENT" tree; CI-ownedpnpm check:dual-build-cjs-loadscheck:error-code-provenanceReverse verification (committed state, marker that survives the build): mutate
isEngineDuplicateRecordEnvelopeinprotocol.tsto compare againstDUPLICATE_RECORD_ABLATED_14723(grep: marker 1, original 0 — landed on disk), rebuild metadata-protocol,ablation-dist-preflight✓ "marker present in 2 built files"; the runtime pin goes red exactly as predicted —Expected: "UNIQUE_VIOLATION"/Received: "DUPLICATE_RECORD"(1 failed | 2 passed); metadata-protocol section 7 goes 2 red / 16 green (the two loop cases; both guards and the anti-vacuity case stay green, as predicted). Restore viagit checkout HEAD -- ABSOLUTE_PATHunder an EXIT/INT/TERM trap:git hash-object=6e66fe99…= the HEAD blob, whole-treegit status --porcelainempty, rebuild, preflight--absent✓ "marker absent from all 24 built files", runtime pin green again (3 passed). A first attempt with afalse &&marker was VOID by the preflight's own rule (the bundler folded the marker out of executable output) and is reported as such, not counted.Consumer direction: no exported type changed (the one new export is rest-internal), so a
pnpm --filter '...@objectstack/metadata-protocol' typechecksweep is not owed.Out of scope, filed
DUPLICATE_RECORDas a 409 HTTP response code, while every route reports the conflict asUNIQUE_VIOLATION#15362 (finding):content/docs/protocol/kernel/error-handling.mdxdocumentsDUPLICATE_RECORDas a 409 HTTP response code with a value-echoing example; the wire spelling on every route isUNIQUE_VIOLATION. Docs drift predating this card; [finding] content/docs/protocol/kernel/error-handling.mdx documentsDUPLICATE_RECORDas a 409 HTTP response code, while every route reports the conflict asUNIQUE_VIOLATION#15362 is not addressed here.🤖 Generated with Claude Code
Generated by Claude Code