Skip to content

fix(mcp): an MCP tool call repeating a unique value answers UNIQUE_VIOLATION, not the engine's DUPLICATE_RECORD (#23701) - #23713

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-23701-mcp-unique-violation
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-23701-mcp-unique-violation

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23701
Clause-②: no

An MCP tool call that repeats a unique value now answers the ruled wire spelling, UNIQUE_VIOLATION, in its isError tool result, instead of the engine's in-process DUPLICATE_RECORD. Only the code moves.

Governing text

Maintainer ruling, recorded in commit 65846bc46, as restated at packages/metadata-protocol/src/protocol.ts:

Maintainer ruling (2026-09-03, recorded in commit 65846bc): one wire spelling on every route, UNIQUE_VIOLATION; the row reports it too.

The engine's thrown identity is not renamed: DuplicateRecordError.code stays DUPLICATE_RECORD in-process. A producer that merely speaks DUPLICATE_RECORD (a hook or an action handler throwing the registered code without being the engine's class) keeps its own code.

What changed

packages/mcp/src/mcp-http-tools.ts, in thrownRefusalEnvelope, decided before the code is written:

  • Key. isEngineDuplicateRecordEnvelope(err), the existing @objectstack/types predicate (already a dependency of this package) that RestServer's arm, the batch and import rows, both Hono doors (PR fix(plugin-hono-server): a Hono door answers the engine's DuplicateRecordError as 409 UNIQUE_VIOLATION, with the arm's fields (#23587) #23682) and the dispatcher (PR fix(runtime): the dispatcher answers the engine's DuplicateRecordError as 409 UNIQUE_VIOLATION, with the arm's fields (#23686) #23700) ask: the registered code AND the class name, never message text. No second translator.
  • Code. For that class, the module-local wireCodeOf reads the code off the shared classification's arm, structuredCodeAnswer (an existing @objectstack/types export, the arm RestServer answers with), which states UNIQUE_VIOLATION for it. Every other throw keeps its thrown code. This package spells no copy of the wire code of its own; see "Spelling" below for why.
  • One writer. thrownRefusalEnvelope is what errorResultWithCode (the data tools and list_actions), errorResultFromThrown (run_action, resume_run) and the record resource (MCPServerRuntime.bridgeResources) all read. Both transports (Streamable HTTP and stdio) register the same tools through wireBridgeTools, so both get the branch.
  • Unchanged. The result stays an isError tool result, status stays 409, message is the engine's sentence as before, and the envelope's keys stay code, message, status. thrownRefusalEnvelope's signature and the exported ThrownRefusalEnvelope interface are unchanged. No export is added. No packages/types, packages/runtime or packages/spec file moves.

The context slot: measured, there is none, so the code alone is carried

The other doors also carry the REST arm's context (developerMessage, object, and field when the driver named the column) in their envelope's details. This envelope has no such slot:

  • ThrownRefusalEnvelope is code, message, status (optional). It reads no details.
  • The data tools (create_record, update_record, and the rest) withhold details by design. errorResultWithCode's docblock says what a refusal's details may disclose is decided per class at the HTTP boundary, that this package holds no copy of that policy, and that code, message and status are what every boundary serves.
  • errorResultFromThrown (run_action, resume_run) relays the thrown value's OWN details. DuplicateRecordError has none, so no measured body carries any.
  • The record resource answers error, code and status.

Measured on the rig below: every positive body's keys are exactly code, message, status, before and after. At the other doors details.developerMessage is the engine's message byte for byte, which this envelope already carries as message. Whether object and field should be added on this surface is the open question at the end.

Spelling

The first spelling at 33683617f was a module constant, 'UNIQUE_VIOLATION' satisfies ErrorCode. pnpm --filter @objectstack/spec run check:error-code-provenance read it as a new emitter: FAIL — 1 stamp site(s) of a registered code with no provenance row: @objectstack/mcp stamps 'UNIQUE_VIOLATION' (constdef) at packages/mcp/src/mcp-http-tools.ts:389. Its remedy is a ledger row or a PROVENANCE_WAIVERS entry, both packages/spec edits outside this card's declared surface. So da084ab11 reads the code off structuredCodeAnswer instead: the stamp stays where the provenance ledger already lists it (@objectstack/types), and this door relays the classification's answer, as the REST door does. The gate is green on it (OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver). Behaviour is the same on every measured row.

Measured before and after (H1, H3)

Rig (scratch, not committed): the real MCPServerRuntime (from packages/mcp source) behind the real HttpDispatcher's /mcp route (dispatch() with a Fetch Request, the identity step stubbed to a member), over a real ObjectQL with SqlDriver (better-sqlite3 :memory:) and a real ObjectStackProtocolImplementation. The metadata service is a listing double. The object udoc declares email unique, and r1 holds the value. Before is the merge base 69351e985 (unfixed). After is the final head da084ab11.

Tool call Before After
create_record on udoc repeating email 200, isError, {"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written.","status":409}} the same body with "code":"UNIQUE_VIOLATION"
update_record giving r2 the value r1 holds the same DUPLICATE_RECORD body the same, UNIQUE_VIOLATION
run_action: a registered handler whose ctx.engine.insert repeats email the same DUPLICATE_RECORD body the same, UNIQUE_VIOLATION
run_action: a declarative operation: 'update' action on r2 the same DUPLICATE_RECORD body the same, UNIQUE_VIOLATION

A diff of the before and after outputs shows exactly these four code lines. Every other byte is identical, the controls included. The store is unchanged in both runs: one row holds the value, and r2 keeps its own.

No measured body carries field. The unique index on this tenancy-scoped object is the composite uniq_udoc_organization_id_email, so the driver names no single column. This envelope would not carry it anyway (above).

Tool census (H2, H3)

Tool (writer) Verdict
create_record (errorResultWithCode) crosses: measured, pinned
update_record (errorResultWithCode) crosses: measured, pinned
run_action (errorResultFromThrown), a handler write or a declarative update crosses: measured, pinned
resume_run (errorResultFromThrown) writer pinned directly. NOT MEASURED on a real engine: packages/mcp composes no engine and packages/runtime composes no automation service. By reading, a resumed run's write does not reach this writer as the engine's class: the flow engine rethrows a node failure as a plain Error (Node 'x' failed: …), and classifyResumeResult answers the failed run as 400 FLOW_FAILED.
run_action on a type: 'flow' action not crossing, by reading: the same flow-failure path, FLOW_FAILED
delete_record, get_record, query_records, aggregate_records, describe_object, validate_expression, list_objects, list_actions (errorResultWithCode) translate by construction (same writer). None of these calls writes a unique value. Not measured
the record resource translates by construction. It is a read, so no measured call reaches it. Pinned, as the reader's second consumer
the stdio face the same tools through wireBridgeTools. stdio-data-bridge's create and update call engine.insert and engine.update, whose throw reaches the same writer. By reading, not measured separately
a batch tool there is none

Controls

Control (rig) Before After
run_action: a handler throws { code: 'DUPLICATE_RECORD', status: 409 }, not the engine's class DUPLICATE_RECORD, 409 the same
create_record on an object whose beforeInsert hook throws { code: 'DUPLICATE_RECORD', status: 409 } DUPLICATE_RECORD, 409 the same
run_action: a handler's ctx.engine.delete hits a restrict lookup DELETE_RESTRICTED, 409 the same
delete_record on the restricted parent DELETE_RESTRICTED, 409 the same

Pins

New file packages/mcp/src/mcp-unique-violation-wire-code.test.ts, 11 rows. The tools run on the HTTP /mcp face (handleHttpRequest over the real Streamable HTTP transport), and the record resource through a real Client over InMemoryTransport. A bridge double throws a value shaped like the engine's class (name, code, status, httpStatus, object, field, its in-process developerMessage, a driver error on cause), because this package composes no engine. Codes and key presence are asserted, never wording.

  • create_record, update_record, run_action, resume_run: UNIQUE_VIOLATION, status 409, isError, keys exactly code, message, status. Neither DUPLICATE_RECORD nor the driver's text appears in the result text.
  • The record resource: code UNIQUE_VIOLATION, status 409, keys exactly code, error, status.
  • CONTROL: a producer that merely speaks DUPLICATE_RECORD keeps it, on create_record, run_action and the resource.
  • CONTROL: the class name with another code (CONCURRENT_UPDATE) keeps that code.
  • CONTROL: DELETE_RESTRICTED on delete_record and run_action keeps its code and keys.

Ablation

At da084ab11, through node scripts/ablation-replace.mjs in wrap mode, under an outer trap … EXIT INT TERM that restores the absolute path with git checkout HEAD --. The key line if (!isEngineDuplicateRecordEnvelope(err)) return thrownCode; was replaced by return thrownCode; // ABLATION_23701_NO_WIRE_MAP.

  • On disk: anchor 1 to 0, marker 0 to 1, blob 378ec1bff879 to 05d1d36e1f14.
  • The pins and the rig import the MCP source by relative path (@objectstack/mcp is not resolved through dist/ in either), so no rebuild was needed. @objectstack/types is unmutated.
  • Pins: Tests 5 failed | 6 passed (11). Exactly the 5 positive rows were red, each expected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION'. All 6 controls were green.
  • Rig: the 8 shared rows are byte-identical to the before run at the merge base.
  • Restore: the blob equals HEAD (378ec1bff879), and git diff HEAD is empty on the path.

The same ablation at 33683617f (the first spelling) read the same: 5 red, 6 green, the rig back to the before bytes.

Tests

At the final head da084ab11. origin/main is still the merge base 69351e985, so there was nothing to merge.

  • pnpm --filter @objectstack/mcp test: Test Files 40 passed (40), Tests 478 passed (478).
  • pnpm --filter @objectstack/mcp typecheck: exit 0. That is tsc --noEmit and then check:test-typecheck: OK, with no new debt. tsc -p tsconfig.test.json --listFiles lists the new test file.
  • eslint --no-inline-config packages/mcp: exit 0, no findings. Repo-wide pnpm lint is CI's.
  • pnpm --filter @objectstack/mcp build: exit 0 (check-dts-emitted: 2/2).

Gates

Local, at the final head da084ab11:

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths: 63 commands derived from the 3 changed paths (merge base 69351e985). All 63 ran, each exit code recorded before any pipe, and all 63 exited 0.
  • --ran reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: all 63 recorded an exit code and none is 3).
  • Among them, all exit 0: pnpm check:dispatcher-error-vocabulary, pnpm check:engine-double-contract, pnpm check:test-source-alias, pnpm check:cross-package-test-inputs, pnpm check:nul-bytes, pnpm check:issue-citations, pnpm check:doc-authoring, pnpm check:published-files, pnpm check:type-check-debt, pnpm check:dual-build-cjs-loads, check-adr-0087-registration, check-empty-changeset and check-changeset-no-major.
  • On the first pass at 33683617f, two of the 63 did not measure, and both were re-run: pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, no workspace dist/), then 0 after a cache-backed pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2 (75 successful, 74 cached); node scripts/check-comment-mask-corpus.mjs exited 1 on an ENOENT for a scratch rig file deleted mid-sweep (not a gate verdict), then 0. At the final head both exited 0 on the first pass.
  • The four artifact-roster gates whose roster sits in a directory this diff touches, plus the provenance gate, all exit 0 at the final head: node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing, pnpm check:filter-alias-parity, pnpm --filter @objectstack/spec run check:error-code-provenance (see "Spelling" for its first-spelling red).
  • Consumer suites: the gates name none. No consumer test pins DUPLICATE_RECORD on an MCP tool result (git grep over the tests of this package's four dependents: @objectstack/cli, @objectstack/verify, packages/qa/dogfood and examples/app-todo). @objectstack/verify's DUPLICATE_RECORD pins are the engine's in-process system door, which does not move. Not run locally; dogfood is the full battery's.
  • Every family CI runs on this PR is reported on its check-runs.

Found, not fixed here

Acceptance notes

  • The provenance ledger does not see the two landed door translations. check:error-code-provenance declares itself blind to a ternary, and the dispatcher (packages/runtime/src/http-dispatcher.ts) and the Hono doors (packages/plugins/plugin-hono-server/src/adapter.ts) stamp 'UNIQUE_VIOLATION' inside one. ERROR_CODE_LEDGER lists UNIQUE_VIOLATION under @objectstack/types, @objectstack/driver-memory and @objectstack/driver-sql only. Observation, outside this file surface, no carrier.
  • The record resource now answers UNIQUE_VIOLATION for the engine's class too, because it reads the same envelope. It is a read, so no measured call reaches it. It is pinned so the change is declared rather than silent.
  • wireCodeOf keeps the thrown code when the arm answers no string code. That branch is unreachable today, because the arm is gated on the same two-part predicate. If the arm ever stopped answering for this class, this door would relay DUPLICATE_RECORD again, and the pins here would go red.
  • Docs: no edit. content/docs/releases/ is untouched.

Open question for the seat

The context slot. A (this PR): the code alone, and the envelope keeps code, message, status on every tool. B: add the arm's object and field in details on run_action and resume_run only, where a details member exists. That gives two bodies for one refusal across tools. C: add them on every tool. That reverses the data tools' declared withholding for this class, and it is a second wire change. Recommended: A, because the dispatch said to carry the code alone where there is no slot, message already carries what developerMessage would, and B or C would widen a surface nobody has measured a reader for.


Generated by Claude Code

…ON, not the engine's DUPLICATE_RECORD

thrownRefusalEnvelope, the one reader both MCP tool writers and the record
resource share, relayed the engine's in-process code verbatim. It now decides
the code before writing it: the engine's DuplicateRecordError, keyed by
isEngineDuplicateRecordEnvelope (@objectstack/types), crosses as the ruled
wire spelling UNIQUE_VIOLATION. A producer that merely speaks
DUPLICATE_RECORD keeps its own code. Status, message and the envelope's
keys are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
… a local literal

The first spelling stamped 'UNIQUE_VIOLATION' as a constant in
@objectstack/mcp, which check:error-code-provenance reads as a new
emitter with no ledger row or waiver (a packages/spec edit this card
does not carry). The code is now read off structuredCodeAnswer
(@objectstack/types), the arm RestServer answers with, behind the same
isEngineDuplicateRecordEnvelope key: one statement of the wire code,
relayed, as the REST door relays it. Behaviour is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLwgbTSmGMrqsXkBDsiwfG
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 12 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 53e3638ea21de79576ff093f9ef476d696941350 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 69f7804e92223aeb0a309a0fcf0a6fccc7be5d1e — the merge of head da084ab11197fd385cc2d5e72ade91d8238fd90b into base 53e3638ea21de79576ff093f9ef476d696941350, 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 69f7804e92223aeb0a309a0fcf0a6fccc7be5d1e && git checkout 69f7804e92223aeb0a309a0fcf0a6fccc7be5d1e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53e3638ea21de79576ff093f9ef476d696941350 da084ab11197fd385cc2d5e72ade91d8238fd90b && git checkout -B drift-repro 53e3638ea21de79576ff093f9ef476d696941350 && git merge --no-ff da084ab11197fd385cc2d5e72ade91d8238fd90b

node scripts/docs-audit/affected-docs.mjs --json 53e3638ea21de79576ff093f9ef476d696941350

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

✅ ACCEPT: PR #23713 at da084ab11 (#23701, Fixes #23701). It queues on a true-green head; no contract review is owed

domain:cli seat 1 (#6024) · os-bill · session session_01FLwgbTSmGMrqsXkBDsiwfG · 2026-10-15T18:56Z · holder of claim 6198461167. Report: os-dev-report 6199848483.

席位核对

  1. Shape, read on GitHub:
    • Draft against main, head da084ab11. Line 1 is Fixes #23701, the only closing keyword. Line 2 is a bare Clause-②: no. Assignee os-bill.
    • Files: 3, +305 / −3, all inside the claim's surface: mcp-http-tools.ts, a new pin file, and the changeset.
    • Not governed. No content/docs/releases/ change. git merge-tree against origin/main is clean. Trailers are the model-free pair.
  2. The fix, read against the ruling (65846bc46: "one wire spelling on every route, UNIQUE_VIOLATION"):
    • thrownRefusalEnvelope is the one reader the data tools, run_action / resume_run and the record resource share. It now passes the code through wireCodeOf. That is the thrown code, except for the engine's DuplicateRecordError, keyed on isEngineDuplicateRecordEnvelope. For that class it is read off structuredCodeAnswer's arm, so @objectstack/mcp stamps no copy of the code.
    • status, message, isError and the envelope's keys are unchanged. No export is added, and no packages/types or packages/spec edit is made.
  3. Measured, before and after, behind the real dispatcher /mcp route over a real engine:
    • create_record, update_record, and run_action (handler write and declarative update) go from DUPLICATE_RECORD to UNIQUE_VIOLATION.
    • The before/after diff is exactly those 4 code lines.
    • resume_run is pinned at the writer and NOT MEASURED on an engine. By reading, a resumed run's write failure answers 400 FLOW_FAILED and never reaches the writer as the engine's class.
    • Controls, unchanged: a hook- or handler-thrown DUPLICATE_RECORD keeps its code; DELETE_RESTRICTED is unchanged; a DuplicateRecordError-named class with another code keeps it.
    • Ablation turns exactly the 5 positives red, and all 6 controls stay green.
    • @objectstack/mcp 478 of 478; typecheck exit 0; 63 of 63 derived gates.
    • check:error-code-provenance is green on the merits. It went red on the first spelling (a module constant), and the arm relay removes the stamp.
  4. Clause-②: no holds. No accepted input moves, and no spec text moves. This is the tool result's code, brought to the ruling. The changeset discloses it as a wire change, with FROM / TO and the one-line client fix. None of the contract-review faces is touched.
  5. The changeset, checked sentence by sentence:
  6. Open questions, answered:
    • Q1: A, the code alone. This envelope has no context slot, and no measured MCP reader needs object / field.
    • Q2: A, relay the arm. The code has one source, and the provenance gate passes on its merits.
  7. Out of scope:

Landing: once every check on da084ab11 is green, the seat marks the PR ready and arms auto-merge. Fixes #23701 closes the card when it merges.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants