Skip to content

fix(metadata-protocol,rest,objectql): a batch/import ROW reports a unique-constraint refusal as UNIQUE_VIOLATION, the route's one wire spelling - #15363

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-14723-unique-violation-one-wire-spelling
Sep 4, 2026
Merged

fix(metadata-protocol,rest,objectql): a batch/import ROW reports a unique-constraint refusal as UNIQUE_VIOLATION, the route's one wire spelling#15363
os-justin merged 1 commit into
mainfrom
claude/issue-14723-unique-violation-one-wire-spelling

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #14723

Draft for the domain:spec seat's Clause ② contract review (needs:contract-review hung 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:

A unique-constraint refusal has one wire spelling on every route: UNIQUE_VIOLATION, the standard-catalog member content/docs/protocol/kernel/http-protocol.mdx documents for the 409 constraint-violation body and what every whole-request door answers. Row-level reports on POST /api/v1/data/:object/batch, POST /api/v1/batch and the import runner report the same member instead of the engine's DUPLICATE_RECORD spelling. In the same stroke: DuplicateRecordError's docblock ("branch on code === 'DUPLICATE_RECORD'") is corrected to name the wire spelling a client branches on; the row pin at packages/runtime/src/batch-row-driver-text-real-driver.integration.test.ts:282 flips; packages/rest/src/import-runner.ts:293 is checked for the same mapping. No waiver entry is added to error-code-ledger.zod.ts: the duplication is removed, not declared.

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.code stays DUPLICATE_RECORD in-process; what moves is what a ROW REPORTS across the HTTP boundary, exactly as the whole-request arm at error-response.ts already maps the class to UNIQUE_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/main 2ed6be649 (fetched 2026-09-04T12:10Z; the seat's readings were at 8f404a51 and all still hold):

  • packages/metadata-protocol/src/protocol.ts:2035 toRowApiError — a thrown registered code went on the row verbatim (ErrorCode.safeParse(err.code).success ? err.code : …), so a DuplicateRecordError row said DUPLICATE_RECORD.
  • packages/rest/src/import-runner.ts:289-297 toFailedResultfirst?.code ?? e?.code ?? 'IMPORT_ROW_FAILED', the same relay for import rows.
  • packages/rest/src/error-response.ts:838-848 documented the split in its own words; :962 is the whole-request arm mapping the class to UNIQUE_VIOLATION.
  • packages/objectql/src/duplicate-record-error.ts:96-99 developerMessage said "branch on code === 'DUPLICATE_RECORD'".
  • The runtime row pin at :282 asserted DUPLICATE_RECORD; error-code-ledger.zod.ts carries no waiver (:262, :777 list UNIQUE_VIOLATION as a member).

Premise holds. Producer located where the dispatch expected it: toRowApiError is the single row-code derivation for POST /data/:object/batch (all three loops build their row through it), and the cross-object POST /api/v1/batch is atomic — it has no per-row error derivation at all; its failure is whole-request through handleRouteError, which already answers UNIQUE_VIOLATION.

What changed

  • packages/metadata-protocol/src/protocol.tstoRowApiError maps the engine's envelope to UNIQUE_VIOLATION before the verbatim registered-code limb, via a package-local isEngineDuplicateRecordEnvelope (code AND name; the class is not imported — this package does not depend on objectql). httpStatus still reads the declared 409; message is still the envelope's platform sentence; the driver's text stays on cause.
  • packages/rest/src/import-runner.tstoFailedResult applies 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 through handleRouteError answer the engine's DUPLICATE_RECORD envelope from resolveErrorResponse's .status passthrough — field and object dropped, mapDataError's structured 409 arms never consulted #14541 review now states the converged rule; isEngineDuplicateRecordEnvelope is 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 of structuredCodeAnswer by its error?.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 and developerMessage name the wire spelling a client branches on (UNIQUE_VIOLATION on every route, whole-request and per-row alike) and the in-process spelling beside it. No issue id in the customer-facing string.
  • Pins — the runtime real-driver row pin flips and additionally asserts no DUPLICATE_RECORD anywhere in the payload (the leak assertions did not move); protocol.batch-row-http-status.test.ts gains section 7 (create / update / upsert loops report UNIQUE_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); new packages/rest/src/import-runner-unique-violation-row.test.ts drives the REAL DuplicateRecordError through runImport on both write paths and pins door-to-row agreement (mapDataError(env).body.code === row.code).
  • Changesets.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 — the developerMessage string ships in dist). Both breaking changesets carry adr-0087: not-required (no-migration-prescription), admitted by the gate. No @objectstack/spec changeset; 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 is DUPLICATE_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 /batch has 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 asserted DUPLICATE_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-vocabulary ran green ("0 unregistered wire code(s) hiding"); check:error-code-provenance does not exist in package.json or scripts/ at this tree. (h) held; objectql owed a patch (runtime string in dist), not skip-changeset.

Deviations from the claim surface

  • packages/rest/src/rest-duplicate-record-arm.test.ts — a 3-line comment that quoted the old developerMessage sentence, 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 in import-runner-error-sanitize.test.ts, whose subject is sanitizeRowError's dialect table.

Measurements, pinned to head 6633fdd19

Every 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).

Check Exit Verdict line
pnpm --filter '@objectstack/rest^...' build (closure) 0 os-verify-lock: VERDICT command-exit 0 · held the lock 259s
pnpm --filter '@objectstack/runtime^...' build + pnpm --filter @objectstack/runtime typecheck 0 check: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 0
metadata-protocol: 5 batch-row/verb test files 0 Test Files 5 passed (5) · Tests 80 passed (80) · VERDICT command-exit 0
rest: import-runner-unique-violation-row, import-runner*, rest-duplicate-record-arm, error-response* (11 files) 0 Test Files 11 passed (11) · Tests 211 passed (211) · VERDICT command-exit 0
runtime: batch-row-driver-text-real-driver.integration.test.ts (real SqlDriver over better-sqlite3) 0 Test Files 1 passed (1) · Tests 3 passed (3) · VERDICT command-exit 0
objectql: engine-autonumber-resync, driver-fault-redaction (untouched) 0 Test Files 2 passed (2) · Tests 82 passed (82) · VERDICT command-exit 0
pnpm --filter @objectstack/objectql --filter @objectstack/metadata-protocol typecheck 0 VERDICT command-exit 0 (metadata-protocol's tsconfig includes its tests, so section 7 is typechecked)
pnpm --filter @objectstack/rest typecheck 0 check:test-typecheck: OK — @objectstack/rest's test layer compiles … 0 file(s) / 0 error(s)
pnpm lint (full eslint . --no-inline-config, on head) 0 no output, exit 0
pnpm check:doc-authoring 0 ✓ doc authoring guard: 14741 customer-facing string(s) across 730 spec sources clean
pnpm check:nul-bytes 0 ✓ check-nul-bytes --self-test: 75 assertions
pnpm check:dispatcher-error-vocabulary 0 0 unregistered wire code(s) hiding
pnpm check:error-code-casing 0 ✓ no unlisted lowercase error codes in 5390 scanned file(s)
pnpm check:error-status-conformance 0 ✓ every derivable runtime status is documented, and every documented status is reachable
node scripts/check-system-context-census.mjs 0 OK — 106 elevation read sites in 20 packages across 45 files, all anchored
node scripts/check-changeset-no-major.mjs --base origin/main 0 ✓ This diff introduces no major bump
node scripts/check-adr-0087-registration.mjs --base origin/main 0 ADR-0087 exemption (no-migration-prescription) admitted for both breaking changesets
node scripts/check-empty-changeset.mjs 0 ✓ No empty-frontmatter changeset introduced by this diff (3 declaring changeset(s) added)
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands 0 47 commands derived at 6633fdd19, 11 paths vs merge base 2ed6be649; all run
derived families (cross-package-test-inputs, test-source-alias, undeclared-dep-imports, closing-keyword-parity, comment-mask-adoption/corpus, keyed-text-bounds, route-envelope, published-files, type-check-coverage, type-source-resolution, engine-double-contract, objectql-double-limit, where-matcher, durability-log-level, docs-audit affected-docs/drift-comment, filter-alias-parity, plugin-teardown-shape, changeset-gate-self-tests, ci-filter-parity, query-options-erasure, slot-lookup, refd-timer-probe, watch-hint-literal, logger-receiver-detach, page-declaration-shape, pm-half-states, objectui-changeset, release-rehearsal-clone self-test, the self-tests) 0 each each gate's own OK/✓ line
node scripts/check-engine-split-ratio.mjs --days 90 0 after deepening as the gate prescribed (git fetch --shallow-since=2026-05-30): horizon 2026-05-30 covers the window
pnpm check:type-check-debt 3 NOT MEASURED — the gate's own text: measuring without every dependency's built dist "would silently measure a DIFFERENT" tree; CI-owned
pnpm check:dual-build-cjs-loads 3 NOT MEASURED — "Run pnpm build first. This is NOT a pass: nothing was measured" (29+ packages unbuilt); CI-owned
check:error-code-provenance does not exist at this tree

Reverse verification (committed state, marker that survives the build): mutate isEngineDuplicateRecordEnvelope in protocol.ts to compare against DUPLICATE_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 via git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap: git hash-object = 6e66fe99… = the HEAD blob, whole-tree git status --porcelain empty, rebuild, preflight --absent ✓ "marker absent from all 24 built files", runtime pin green again (3 passed). A first attempt with a false && 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' typecheck sweep is not owed.

Out of scope, filed

🤖 Generated with Claude Code


Generated by Claude Code

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/rest, touching 11 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via data.batch (sdk, the route ledger binds it to POST /api/v1/data/:object/batch, selected by route anchor /data/:object/batch))
  • content/docs/api/data-api.mdx (via /data/:object/batch (route, a path literal on a changed line))
  • content/docs/api/error-catalog.mdx (via DUPLICATE_RECORD (literal, a string literal in isEngineDuplicateRecordEnvelope; a string literal in structuredCodeAnswer; a string literal on a changed line))
  • content/docs/api/wire-format.mdx (via /data/:object/batch (route, a path literal on a changed line), /data/:object/import (route, a path literal on a changed line))
  • content/docs/automation/flows.mdx (via DUPLICATE_RECORD (literal, a string literal in isEngineDuplicateRecordEnvelope; a string literal in structuredCodeAnswer; a string literal on a changed line))
  • content/docs/data-modeling/drivers.mdx (via UNIQUE_VIOLATION (literal, a string literal in constructor; a string literal in toFailedResult; a string literal in toRowApiError))
  • content/docs/data-modeling/import-mappings.mdx (via /data/:object/import (route, a path literal on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via DUPLICATE_RECORD (literal, a string literal in isEngineDuplicateRecordEnvelope; a string literal in structuredCodeAnswer; a string literal on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via UNIQUE_VIOLATION (literal, a string literal in constructor; a string literal in toFailedResult; a string literal in toRowApiError), /data/:object/batch (route, a path literal on a changed line))

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

  • content/docs/releases/implementation-status.mdx (via /data/:object/batch (route, a path literal on a changed line))
  • content/docs/releases/v12.mdx (via /data/:object/import (route, a path literal on a changed line))
  • content/docs/releases/v16.mdx (via data.batch (sdk, the route ledger binds it to POST /api/v1/data/:object/batch, selected by route anchor /data/:object/batch))
  • content/docs/releases/v17.mdx (via UNIQUE_VIOLATION (literal, a string literal in constructor; a string literal in toFailedResult; a string literal in toRowApiError), /data/:object/batch (route, a path literal on a changed line))

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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 29 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 c4d1354e32539eb418e35757ccbafb3dea57ba93packageMentionDocs.

Which tree this was computed on

This run read content/docs from 801b010f4cf8eb3ba4437c4e32d8ff8a9698c8e6 — the merge of head 6633fdd196722b99df8c8b28472603aca1606dfa into base c4d1354e32539eb418e35757ccbafb3dea57ba93, 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 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

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

Copy link
Copy Markdown
Collaborator Author

Landing provenance — domain:spec seat (session session_01H2oQebDDxYKfWZusyd8GXk): Clause ② contract review PASS · ACCEPT on the card (#14723, comment 5540731334) at head 6633fdd19; needs:contract-review cleared on the card and on this PR (both read back). Flipped ready and auto-merge armed (squash through the merge queue) at 2026-09-04T12:56Z; the queue admits it once CI on this head is green. On MERGED the seat strips pm:dispatched from #14723 and probes toRowApiError on origin/main.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants