Repository navigation
fix(mcp): an MCP tool call repeating a unique value answers UNIQUE_VIOLATION, not the engine's DUPLICATE_RECORD (#23701) - #23713
Conversation
…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
📓 Docs Drift Check2 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
Coarse fallback — 12 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 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 |
✅ ACCEPT: PR #23713 at
|
Fixes #23701
Clause-②: no
An MCP tool call that repeats a unique value now answers the ruled wire spelling,
UNIQUE_VIOLATION, in itsisErrortool result, instead of the engine's in-processDUPLICATE_RECORD. Only the code moves.Governing text
Maintainer ruling, recorded in commit
65846bc46, as restated atpackages/metadata-protocol/src/protocol.ts:The engine's thrown identity is not renamed:
DuplicateRecordError.codestaysDUPLICATE_RECORDin-process. A producer that merely speaksDUPLICATE_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, inthrownRefusalEnvelope, decided before the code is written:isEngineDuplicateRecordEnvelope(err), the existing@objectstack/typespredicate (already a dependency of this package) thatRestServer'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.wireCodeOfreads the code off the shared classification's arm,structuredCodeAnswer(an existing@objectstack/typesexport, the armRestServeranswers with), which statesUNIQUE_VIOLATIONfor 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.thrownRefusalEnvelopeis whaterrorResultWithCode(the data tools andlist_actions),errorResultFromThrown(run_action,resume_run) and the record resource (MCPServerRuntime.bridgeResources) all read. Both transports (Streamable HTTP and stdio) register the same tools throughwireBridgeTools, so both get the branch.isErrortool result,statusstays409,messageis the engine's sentence as before, and the envelope's keys staycode,message,status.thrownRefusalEnvelope's signature and the exportedThrownRefusalEnvelopeinterface are unchanged. No export is added. Nopackages/types,packages/runtimeorpackages/specfile 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, andfieldwhen the driver named the column) in their envelope'sdetails. This envelope has no such slot:ThrownRefusalEnvelopeiscode,message,status(optional). It reads nodetails.create_record,update_record, and the rest) withholddetailsby design.errorResultWithCode's docblock says what a refusal'sdetailsmay disclose is decided per class at the HTTP boundary, that this package holds no copy of that policy, and thatcode,messageandstatusare what every boundary serves.errorResultFromThrown(run_action,resume_run) relays the thrown value's OWNdetails.DuplicateRecordErrorhas none, so no measured body carries any.error,codeandstatus.Measured on the rig below: every positive body's keys are exactly
code,message,status, before and after. At the other doorsdetails.developerMessageis the engine'smessagebyte for byte, which this envelope already carries asmessage. Whetherobjectandfieldshould be added on this surface is the open question at the end.Spelling
The first spelling at
33683617fwas a module constant,'UNIQUE_VIOLATION' satisfies ErrorCode.pnpm --filter @objectstack/spec run check:error-code-provenanceread 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 aPROVENANCE_WAIVERSentry, bothpackages/specedits outside this card's declared surface. Soda084ab11reads the code offstructuredCodeAnswerinstead: 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(frompackages/mcpsource) behind the realHttpDispatcher's/mcproute (dispatch()with a FetchRequest, the identity step stubbed to a member), over a realObjectQLwithSqlDriver(better-sqlite3:memory:) and a realObjectStackProtocolImplementation. The metadata service is a listing double. The objectudocdeclaresemailunique, andr1holds the value. Before is the merge base69351e985(unfixed). After is the final headda084ab11.create_recordonudocrepeatingemail200,isError,{"error":{"code":"DUPLICATE_RECORD","message":"Duplicate record refused on 'udoc': a unique constraint already holds these values. No record was written.","status":409}}"code":"UNIQUE_VIOLATION"update_recordgivingr2the valuer1holdsDUPLICATE_RECORDbodyUNIQUE_VIOLATIONrun_action: a registered handler whosectx.engine.insertrepeatsemailDUPLICATE_RECORDbodyUNIQUE_VIOLATIONrun_action: a declarativeoperation: 'update'action onr2DUPLICATE_RECORDbodyUNIQUE_VIOLATIONA
diffof the before and after outputs shows exactly these fourcodelines. Every other byte is identical, the controls included. The store is unchanged in both runs: one row holds the value, andr2keeps its own.No measured body carries
field. The unique index on this tenancy-scoped object is the compositeuniq_udoc_organization_id_email, so the driver names no single column. This envelope would not carry it anyway (above).Tool census (H2, H3)
create_record(errorResultWithCode)update_record(errorResultWithCode)run_action(errorResultFromThrown), a handler write or a declarative updateresume_run(errorResultFromThrown)packages/mcpcomposes no engine andpackages/runtimecomposes 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 plainError(Node 'x' failed: …), andclassifyResumeResultanswers the failed run as400 FLOW_FAILED.run_actionon atype: 'flow'actionFLOW_FAILEDdelete_record,get_record,query_records,aggregate_records,describe_object,validate_expression,list_objects,list_actions(errorResultWithCode)wireBridgeTools.stdio-data-bridge'screateandupdatecallengine.insertandengine.update, whose throw reaches the same writer. By reading, not measured separatelyControls
run_action: a handler throws{ code: 'DUPLICATE_RECORD', status: 409 }, not the engine's classDUPLICATE_RECORD, 409create_recordon an object whosebeforeInserthook throws{ code: 'DUPLICATE_RECORD', status: 409 }DUPLICATE_RECORD, 409run_action: a handler'sctx.engine.deletehits arestrictlookupDELETE_RESTRICTED, 409delete_recordon the restricted parentDELETE_RESTRICTED, 409Pins
New file
packages/mcp/src/mcp-unique-violation-wire-code.test.ts, 11 rows. The tools run on the HTTP/mcpface (handleHttpRequestover the real Streamable HTTP transport), and the record resource through a realClientoverInMemoryTransport. A bridge double throws a value shaped like the engine's class (name,code,status,httpStatus,object,field, its in-processdeveloperMessage, a driver error oncause), because this package composes no engine. Codes and key presence are asserted, never wording.create_record,update_record,run_action,resume_run:UNIQUE_VIOLATION,status409,isError, keys exactlycode,message,status. NeitherDUPLICATE_RECORDnor the driver's text appears in the result text.codeUNIQUE_VIOLATION,status409, keys exactlycode,error,status.DUPLICATE_RECORDkeeps it, oncreate_record,run_actionand the resource.CONCURRENT_UPDATE) keeps that code.DELETE_RESTRICTEDondelete_recordandrun_actionkeeps its code and keys.Ablation
At
da084ab11, throughnode scripts/ablation-replace.mjsin wrap mode, under an outertrap … EXIT INT TERMthat restores the absolute path withgit checkout HEAD --. The key lineif (!isEngineDuplicateRecordEnvelope(err)) return thrownCode;was replaced byreturn thrownCode; // ABLATION_23701_NO_WIRE_MAP.378ec1bff879to05d1d36e1f14.@objectstack/mcpis not resolved throughdist/in either), so no rebuild was needed.@objectstack/typesis unmutated.Tests 5 failed | 6 passed (11). Exactly the 5 positive rows were red, eachexpected 'DUPLICATE_RECORD' to be 'UNIQUE_VIOLATION'. All 6 controls were green.378ec1bff879), andgit diff HEADis 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/mainis still the merge base69351e985, 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 istsc --noEmitand thencheck:test-typecheck: OK, with no new debt.tsc -p tsconfig.test.json --listFileslists the new test file.eslint --no-inline-config packages/mcp: exit 0, no findings. Repo-widepnpm lintis 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 base69351e985). All 63 ran, each exit code recorded before any pipe, and all 63 exited 0.--ranreconciliation:63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN(a derived zero: all 63 recorded an exit code and none is 3).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-changesetandcheck-changeset-no-major.33683617f, two of the 63 did not measure, and both were re-run:pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, no workspacedist/), then 0 after a cache-backedpnpm turbo run build --filter='!@objectstack/docs' --concurrency=2(75 successful,74 cached);node scripts/check-comment-mask-corpus.mjsexited 1 on anENOENTfor a scratch rig file deleted mid-sweep (not a gate verdict), then 0. At the final head both exited 0 on the first pass.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).DUPLICATE_RECORDon an MCP tool result (git grepover the tests of this package's four dependents:@objectstack/cli,@objectstack/verify,packages/qa/dogfoodandexamples/app-todo).@objectstack/verify'sDUPLICATE_RECORDpins are the engine's in-process system door, which does not move. Not run locally; dogfood is the full battery's.Found, not fixed here
run_actionover a sandboxed action body still answersDUPLICATE_RECORD. Measured on the rig atda084ab11: atype: 'script'action whosebody(bound bybindAppArtifactHandlers, withcapabilities: ['api.write']) callsctx.api.object('udoc').insert(…)repeatingemailanswered{"error":{"code":"DUPLICATE_RECORD","message":"action 'sbx_duplicate' threw: DuplicateRecordError: Duplicate record refused on 'udoc': …","status":409}}. The refusal leaves the sandbox without the class name, so the shared key does not match. That is the sandbox relay's question, which runtime: an engineDuplicateRecordErrora sandboxed script body lets propagate leaves the VM asSandboxError, so no door recognises it and it answers409 DUPLICATE_RECORD, not the ruledUNIQUE_VIOLATION#23702 carries (triage ruled direction A there). runtime: an engineDuplicateRecordErrora sandboxed script body lets propagate leaves the VM asSandboxError, so no door recognises it and it answers409 DUPLICATE_RECORD, not the ruledUNIQUE_VIOLATION#23702 is not addressed here.Acceptance notes
check:error-code-provenancedeclares 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_LEDGERlistsUNIQUE_VIOLATIONunder@objectstack/types,@objectstack/driver-memoryand@objectstack/driver-sqlonly. Observation, outside this file surface, no carrier.UNIQUE_VIOLATIONfor 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.wireCodeOfkeeps 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 relayDUPLICATE_RECORDagain, and the pins here would go red.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,statuson every tool. B: add the arm'sobjectandfieldindetailsonrun_actionandresume_runonly, where adetailsmember 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,messagealready carries whatdeveloperMessagewould, and B or C would widen a surface nobody has measured a reader for.Generated by Claude Code