fix(automation): create_record surfaces the engine's DUPLICATE_RECORD code - #14948
Conversation
… code engine.insert (#14095) raises DuplicateRecordError with a classified `code: 'DUPLICATE_RECORD'` (ADR-0112), driver-independent. The create_record node executor threw it away, collapsing every failure into one opaque string, so a flow's only two error-handling primitives -- try_catch and a fault edge -- could not tell "already there" from "the store is down". - NodeExecutionResult gains an optional `code?: string`, beside the existing `errorClass`, set (via a duck-typed StandardErrorCode check, not an objectql-class import -- objectql is this package's devDependency only) when create_record's catch sees the classified code. - AutomationEngine copies it onto the `$error` run variable beside `message` when a node fails by returning. - try_catch's executor preserves it across its own errorVariable binding, which previously reconstructed `{ nodeId, message }` from the caught exception's message alone and silently dropped it. Deliberately scoped to create_record: update_record / delete_record collapse the same way, but engine.update still leaks the raw driver error (#14390, not yet fixed), so those node results have nothing structured to surface yet. Fixes #14419 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…eate-record-duplicate-code
pre-push refused a stale merged artifact: the merge brought in an auth-plugin.ts line shift the census's anchor didn't follow. Regenerated via `pnpm gen:system-context-census` per AGENTS.md's merge-driver deferral protocol (this is the discharging commit right after the merge, not the merge itself). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 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 a186980c8145f378df9c735037cbcc5865b7eeb1 && git checkout a186980c8145f378df9c735037cbcc5865b7eeb1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c7237c8d66b94e35e53807e9ecd714b80a437fa 90879322a6aa1f4365bd40b0970ee056702f244f && git checkout -B drift-repro 9c7237c8d66b94e35e53807e9ecd714b80a437fa && git merge --no-ff 90879322a6aa1f4365bd40b0970ee056702f244f
node scripts/docs-audit/affected-docs.mjs --json 9c7237c8d66b94e35e53807e9ecd714b80a437fa
|
Docs Drift Check disposed — both rows correctly listed, neither page needs an edit
Both rows anchored on the literal
Grepped both pages for the surface this PR actually changes — Control fired ( ⇒ This diff changes what a flow's node result carries. It does not change the code's meaning, its HTTP status, or where the engine raises it, so nothing on either page becomes false. ⛔ No edit owed, and an edit here would be scope creep on a card whose ruling is explicitly narrow.
Generated by Claude Code |
Contract review verdict:
|
…, test corrections, spec-gap deferral Tier contract review on PR #14948 returned PASS WITH REQUIRED PATCHES. Three patches, all addressed: P1 (the round's reason — a correctness defect the review reproduced): try_catch reads `code` off the run-wide $error, but the engine only rewrites $error when a failing node RETURNS a failure, or THROWS through a node with its own `fault` edge. A node inside a try_catch's try region never has a fault edge of its own (the region's synthetic sub-flow carries only the region's own edges), so a node that fails by THROWING (a timeoutMs firing, a dying nested container) used to leave $error exactly as an earlier, unrelated failure left it -- including its code. Fixed with a minimal identity guard in try-catch-node.ts: capture $error at the start of each attempt, trust the post-catch $error only if it actually changed. Two repro flows pinned (a loop sweeping a duplicate row then a timing-out row; a plain flow where an earlier fault-routed duplicate must not leak into a later unrelated try_catch) -- verified red on the pre-guard code, green after. P2: packages/spec's TryCatchErrorValueSchema doesn't declare `code` yet and strips it on a strict parse. packages/spec is single-owner (domain:spec); deferred and filed as #14954, named in the changeset. content/docs/automation/flows.mdx (hand-written) now mentions {$error.code}; content/docs/references/automation/control-flow.mdx (generated from spec) is untouched. P3: the "sets code: DUPLICATE_RECORD" regression pin never actually asserted `code`, and its comment about the step log carrying it was wrong. Both regression-pin tests now register a fault-edge handler that reads $error directly and assert `code` on it. Fold-in: NodeExecutionResult.code's JSDoc now states create_record narrows deliberately to DUPLICATE_RECORD; changeset notes a custom IDataEngine throwing { code: 'DUPLICATE_RECORD' } directly is correctly duck-typed as a duplicate too (ADR-0112). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Patch round verified at the artifacts —
|
| # | Required | Verified at | Result |
|---|---|---|---|
| P1 | identity guard on the stale $error code |
try-catch-node.ts:162 + :192 |
✅ |
| P2 | declare the spec gap; update the hand-written docs row | changeset + flows.mdx:1083 |
✅ |
| P3 | make the regression pins actually assert code |
test file, +213 lines | ✅ |
P1 — const errorBefore = variables.get('$error') before the attempt, and innerError !== errorBefore && … at the read. The comment states the reasoning that matters and that I would not want re-derived: identity, not content, "because two failures can legitimately share a message."
P2 — better than what I ordered. I asked for {$error.code} on the row; the shipped line also states the semantics: "absent otherwise, so a handler branching on it should treat 'unset' as 'no classified code', not as 'nothing failed'". That is the trap a reader of the bare token would fall into. control-flow.mdx correctly untouched (generated from the spec describe, and #14954 owns the spec half).
P3 — both pins now register a real { type: 'fault' } edge and assert on the $error the handler is actually handed, not on a returned object. The first is titled for the directly-declared fault edge, which also covers the engine.ts read path the review noted nothing tested. The file header now states the bar in its own words rather than leaving it implied.
engine.ts in this round is comment-only — verified, not assumed: filtering the round's diff for changed non-comment lines in that file returns empty. The 29-line delta is the corrections to claims the review falsified, which is exactly where they belonged.
Suite 1207 → 1209, +2 for the repro pins — so they ran. The defect was reproduced red on the pre-guard code and green after, in-worktree this round, with the marker confirmed present before and absent after.
Recorded because it is a measurement about this lane, not this PR
The dev's lock accounting for this round: 4 acquisitions, every one a single blocking foreground call finished in the same turn — combined wait 1 second, combined held ~673s of real compute, zero timeouts, zero retries. Against the same agent's earlier rounds, which spent a full turn without acquiring at all. Acquire once, stay blocked. Feeding that to #14944.
It also reported, unprompted, that it had initially mis-invoked several direct-node gate scripts as pnpm run check:* aliases and corrected them to node scripts/check-*.mjs — check-tenant-audit-census among them. That is a distinct failure class from #14880: not a gate that was never derived, but a derived gate invoked under a name that is not a command, which produces no verdict and no red. I have passed it to the sibling dev as a lead on a live gate failure elsewhere.
Status
needs:contract-review cleared from this PR and from #14419, both with a comparative read-back. CI on 90879322a is still running — 18 success, 2 skipped, 11 in progress, zero failures. ⛔ Not armed, and zero failures so far is not a pass; landing waits on the job-level result.
Generated by Claude Code
Fixes #14419
What
create_recordcollapsed everydata.insert()failure into one opaque string(literally
create_record(OBJECT_NAME) failed: MESSAGE_TEXT), so a flow'sonly two error-handling primitives —
try_catchand afaultedge — couldnot tell "the row is already there" from "the store is down".
engine.insert(#14095) already raises
DuplicateRecordError—code: 'DUPLICATE_RECORD'(ADR-0112), driver-independent — but the executor's catch threw it away.
Per the ruling of record (issue comments 5505584657 / 5506297499):
codeon the node result, beside the existingerrorClass.NodeFailureClassmember — that vocabulary is authoring-surface, not this repair's call.create_recordonly —update_record/delete_recordcollapse identically, butengine.updatestill leaks the raw driver error (engine.updatestill leaks the raw driver unique-violation error — the same defect one verb over from the insert door, with the whole UPDATE statement as its message #14390, not yet fixed); acodethose verbs never set would be worse than the string they have now.Changes
NodeExecutionResultgains an optionalcodefield (string, besideerrorClass) —packages/services/service-automation/src/engine.ts.create_record's catch sets it when the caught error carries the platform's classifiedDUPLICATE_RECORDcode —packages/services/service-automation/src/builtin/crud-nodes.ts. This readscodeduck-typed off the thrown value (matchingDuplicateRecordError's own documented contract — branch on the code string, not on a driver dialect or the message), never importing@objectstack/objectql'sDuplicateRecordErrorclass:objectqlis this package'sdevDependencyonly (check:undeclared-dep-importscatches the alternative), because this executor runs against anyIDataEngine, not a concrete engine. Forwarded narrowly — only when it equalsDUPLICATE_RECORD, not any code an as-yet-unaudited driver error might someday carry.AutomationEngine.executeNodecopiescodeonto the$errorrun variable besidemessage, so a directly-declaredfaultedge handler can read it off$error.try_catch's executor now preserves thatcodeacross its ownerrorVariablebinding — previously it reconstructed a plain node-id-plus-message object from the caught exception's message alone and silently dropped whatever the engine had already written to$error, which is what made the catch region unable to discriminate at all. Patch round 1 adds an identity guard here — see below.content/docs/permissions/system-context.mdx), mechanical:node scripts/check-system-context-census.mjs --fixre-anchored a line-number citation my new import line shifted by one.content/docs/automation/flows.mdx$errortable row now mentions{$error.code}alongside{$error.nodeId}/{$error.message}, with the "absent, not universal" caveat.content/docs/references/automation/control-flow.mdxis untouched — it is generated from the specdescribe()and moves only when spec does (see Patch round 1 below).Why
try-catch-node.tsandengine.ts, not justcrud-nodes.tsThe dispatch named
crud-nodes.tsas where the collapse lives, and that isstill the only place a
codegets SET. The other two files are read pathsfor that same value, and both are necessary for the fix to be anything more
than a field nobody can reach:
engine.ts,NodeExecutionResult: this is the declaration site theruling means by "the node result" —
codehas to be declared somewhere,and this interface (already carrying
errorClass) is it. No behaviouralchange here, a type addition only.
engine.ts,executeNode's$errorwrite: separate from thedeclaration, this is the ONE place the engine turns a failing node's
result into something a flow can read (
$error, used by a directlyattached
faultedge handler, and read bytry_catchbefore it forms itsown binding — see below). Before this change it copied
nodeIdandmessageonly;codewas set on the result but never reached$error,so nothing downstream could have seen it regardless of
crud-nodes.ts.This line already existed and already builds this object — the diff adds
one field to it.
try-catch-node.ts: this file already existed and alreadyOVERWRITES
$error(or a customerrorVariable) with its ownreconstruction —
{ nodeId, message }— built from the caughtexception's
.messagestring alone, discarding whatever the engine hadjust written above. Left unpatched, this is the exact mechanism that
would have made the fix inert for the one primitive the ruling names
explicitly as the discrimination target: "a flow with a
try_catchthatswallows the duplicate and re-raises a store failure". Without this
change,
codewould reach$errorat the failing node's own level andthen be destroyed the moment
try_catch's catch region tries to read it.The change is a preservation, not new logic: read the
codealreadysitting on
$error(from the write above) before it gets overwritten,carry it into the new object. Patch round 1 tightened this same
preservation — read on.
On the fence —
⛔ do not add a NodeFailureClass member: neither changetouches it.
NodeFailureClass('runtime' | 'guard') is untouched, stillexactly two members, and the routing decision that reads it
(
result.errorClass === 'guard' ? undefined : …) is byte-identical tobefore this PR.
codeis a new, independent field of a different type(
string, not theNodeFailureClassunion) — it is not read by, does notfeed, and does not widen that vocabulary anywhere in this diff. A flow
branches on
codethe same way it already branches on any other flowvariable value (
$record.status,$error.message, …) — nothing here addsa new thing an author matches on in the sense the ruling was fencing off
(no new label a
decisionnode'sconditionscan name that didn't alreadyexist, no new edge type, no new node-level routing primitive).
Why this earns the card, not just the field
The ruling is explicit that asserting
result.codeequalsDUPLICATE_RECORDis not the bar — that passes with a
faultedge that still cannot branch. Thenew test file adds a real flow:
try_catchwrapscreate_record; thecatchregion reads the bound error's
codeand either swallows (duplicate) orre-raises (anything else); the outer flow has a plain edge AND a
faultedgeoff the
try_catchnode.Two tests run the same flow shape with two different
data.insertfailuresand assert on which nodes actually ran:
DuplicateRecordErrorthrown → swallowed, then after (plain edge,try_catchsucceeds)Errorthrown → reraised, then escalate (fault edge,try_catchfails)An ablation (predicted, then measured, then restored — see Tests) confirms
this is load-bearing: stripping the
codefield collapses BOTH cases onto thesame path.
Patch round 1 (tier contract review — three required patches)
P1, a correctness defect the review reproduced.
try_catchreadscodeoff the run-wide
$error, but the engine only rewrites$errorwhen afailing node returns a failure, or throws through a node with its own
faultedge (engine.ts'sexecuteNode, the throw arm) — and a node insidea
try_catch'stryregion never has a fault edge of its own, because theregion's synthetic sub-flow carries only the region's own edges. So a node
that fails by throwing (a
timeoutMsfiring, a dying nested container, athrown guard) used to leave
$errorexactly as an earlier, unrelatedfailure left it — its
codeincluded. Two flows reproduce it: aloopsweeping two rows where row 1 is a genuine duplicate (swallowed correctly)
and row 2's store hangs into a
timeoutMs(previously bound as{ code: 'DUPLICATE_RECORD', message: "…timed out…" }and swallowed too —a store failure misread as a duplicate, this card's own failure mode through
a different door); and a plain flow where an earlier fault-routed duplicate
on node A leaked into a completely unrelated later
try_catcharound node B.Fixed with a minimal identity guard: capture
$errorat the start ofeach try-region attempt, and only trust the post-catch
$erroras thisattempt's failure if it changed (
!==) from that snapshot — not merely if itis still present. Both repro flows are now pinned (
create-record-duplicate-code.test.ts,third
describeblock) and were verified red on the pre-guard code, greenafter (reproduced locally: reverting the guard's comparison back to the old
form fails exactly those two new tests and none of the other four; restoring
it turns all six green again).
P2, the
packages/specgap — deferred, not fixed here.TryCatchErrorValueSchemadeclares the
errorVariablebinding shape (nodeId,message,iteration,item) as one shape shared by author, engine and run log, and does notdeclare
codeyet — a strict parse strips it.packages/specis single-owner(
domain:spec); this lane does not touch it. Filed as #14954, named inthe changeset. The hand-written
content/docs/automation/flows.mdxrow isupdated to mention
{$error.code}(see Changes); the generatedcontent/docs/references/automation/control-flow.mdxis deliberatelyuntouched — it moves only when spec's own
describe()does.P3, a test that asserted nothing. The original "sets
code: DUPLICATE_RECORD"regression pin checked
success, step status and error text — nevercodeitself, and its comment claiming the step log was where a node-level
code"actually lands" was wrong (
StepLogEntryhas nocode; itserror.codeisthe constant
'NODE_FAILURE'). Both regression-pin tests (firstdescribeblock) are rewritten to register a
fault-edge handler that reads$errordirectly and assert
codeon it — which also exercises the direct-fault-edgeread path (
engine.ts'sexecuteNode, outside anytry_catch) that this PRbody claims and that nothing previously tested.
Fold-in, non-blocking: the JSDoc on
NodeExecutionResult.codenow saysplainly that
create_recordnarrows deliberately toDUPLICATE_RECORDratherthan promising "any classified code" (see Changes); and the changeset notes
that a custom
IDataEnginethrowing{ code: 'DUPLICATE_RECORD' }directly(without being an instance of
DuplicateRecordError) is duck-typed as aduplicate too — correct under ADR-0112, since
codeis the envelope's publiccontract, not the concrete class. The review's note 2 (a
try_catchwhosecatch itself re-raises returns without forwarding a
code) is out of thisround's scope, as directed.
Contract review (Clause-②)
NodeExecutionResultis exported at@objectstack/service-automation'spackage root (
src/index.ts), so the newcodefield is a new payload key ona published surface — measured on the built
dist/index.d.ts, not asserted:grepping the built declaration file for the
NodeExecutionResultinterfaceshows
codeoptional-string alongsidesuccess,outputanderror. Andconfirmed as newly added against
origin/main:returns exactly one added line declaring the new optional field. Additive-only
(optional field, no existing field or behaviour changed) — flagging for the
seat's
needs:contract-reviewcall, not asserting the outcome.Tests
packages/services/service-automation/src/builtin/create-record-duplicate-code.test.ts— now 6 tests across 3describeblocks:$error-level regression pins, each registering afault-edge handler and assertingcodeon the$errorit receives (DUPLICATE_RECORDset / unset) — corrected in patch round 1, see above;try_catchflow above) — the pair that earns the card;coderepro tests (aloopover two rows, and a plain flow with an earlier unrelated fault-routed failure) — reproduced red on the pre-guard code, green with the identity guard.pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2— 102 files / 1209 tests passed on the final head commit (after patch round 1; 1207 before it, +2 for the new repro tests).tsc --noEmit -p packages/services/service-automation/tsconfig.json— only the 3 pre-existing DEBT-ledgered errors innested-region-parity.test.ts(unrelated; matchesscripts/check-type-check-coverage.mjs's ledger entry verbatim), zero new errors, reconfirmed after patch round 1.codeassignment incrud-nodes.ts(literal-text replace, occurrence count confirmed 1 to 0 before running, marker confirmed present) — re-ran the discrimination test file — the "swallows DUPLICATE_RECORD" test failed as predicted (took the reraised/escalate path instead of swallowed/after); the other tests stayed green. Restored via agit checkoutfromHEAD(file was committed first) — blob hash matchedHEAD's exactly, the diff againstHEADwas empty — re-ran the test file — all green again.!==comparison back to the pre-patch form (marker comment confirmed present, then removed) — the two new stale-coderepro tests failed exactly as the review predicted, the other four in the file stayed green — restored the guard (marker confirmed gone) — all six green.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(re-derived after the final commit) — every matched gate run; all green except:check:dual-build-cjs-loads— exit 3, NOT MEASURED (needs a fullpnpm build, out of local-verification scope per this repo's own convention; CI runs it).check:type-check-debt— exit 3, NOT MEASURED (its own re-measure is a maintainer-only act).check:test-completeness— exit 3, NOT MEASURED (grades a savedturbo run testlog; CI supplies one, there is none locally).check:system-context-censusline-rot repaired via its own--fix(see Changes and the follow-up regen-discharge commit after mergingorigin/main, which shifted a different anchor); re-verified clean.check:undeclared-dep-importscaught the first draft's@objectstack/objectqlruntime import (devDependency only) — fixed by switching to the duck-typedStandardErrorCodecheck described above; re-verified clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8