Repository navigation
fix(service-automation,runtime): a refused write inside a flow reaches every FLOW_FAILED door as details.innerRefusal and a top-level userMessage, message unchanged - #25147
Conversation
… result (#24914) Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…24914) Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
… wire (#24914) Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…nner refusal (#24914) Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…patch predicates (#24914) Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 30 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 c83f7791aaf495784608afa275bce314f296de11 && git checkout c83f7791aaf495784608afa275bce314f296de11
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 459afea1f69ff69422de2a7a2c7674b6f7ea1aca 0a2d128cdfdeb7382fce8a7dad14c57eb8cbf509 && git checkout -B drift-repro 459afea1f69ff69422de2a7a2c7674b6f7ea1aca && git merge --no-ff 0a2d128cdfdeb7382fce8a7dad14c57eb8cbf509
node scripts/docs-audit/affected-docs.mjs --json 459afea1f69ff69422de2a7a2c7674b6f7ea1aca
|
Contract reviewServed-tier: The review is owed by the ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对
Changeset sentences checked against the diff:
All hold. Generated by Claude Code |
Fixes #24914
Clause-②: yes (widening)
Metadata-protocol: none
Step (2) of ruling B′ on #24914 (the maintainer's 「同意」, ruling record 6303788292, decision batch #379 item 5). The spec half landed in #24996 (PR #25037):
AutomationResult.userMessage,AutomationResult.innerRefusal(FlowInnerRefusal) and oneFlowFailureDetailsSchemafor the four doors. This PR emits exactly what those declare.The console half is objectstack-ai/objectui#13285 (step 3). It stays open; this PR does not touch objectui. The object API name inside the unique-refusal sentence is #24951 (engine), unchanged here.
What changes
Before: a refused write inside a flow reached the caller as one string. The node's catch kept only the message, the engine prefixed the node id, and each door served that string as the
FLOW_FAILEDmessage:After: the same 400 also carries the refusal structurally.
messageis byte-identical to before.{ "success": false, "error": { "code": "FLOW_FAILED", "httpStatus": 400, "message": "Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': …", "userMessage": "(present only when the producer marked one, e.g. an app hook)", "details": { "summary": { "...": "..." }, "innerRefusal": { "code": "UNIQUE_VIOLATION", "message": "Duplicate record refused on 'crm_account': …" } } } }@objectstack/service-automationsrc/inner-refusal.ts.refusalCarriedBy(err)is the one helper the four CRUD catch arms call. It returns{ userMessage?, innerRefusal? }:userMessagecomes fromdeclaredUserMessage(err)(@objectstack/types), the one read every boundary uses.innerRefusalis set only for a refusal, meaning a throw that declared a 4xx answer (resolveThrownHttpError(err).declaredStatus). A fault carries none: that covers an undeclared throw and a declared 5xx.codeis the one the data door'serrorFromThrownanswers for the same throw. That is the resolver's code (VALIDATION_FAILED,PERMISSION_DENIED, or the member an app hook's declared status names), except that the engine's duplicate envelope takes the ruled wire spelling. That spelling is read off the shared arm (structuredCodeAnswer), never typed here.builtin/crud-nodes.ts. The four catch arms (get_record,create_record,update_record,delete_record) spreadrefusalCarriedBy(err)beside an unchangederror.create_recordkeeps its in-flowcode: 'DUPLICATE_RECORD'for$error.code.engine.ts.NodeExecutionResultgains optionaluserMessage/innerRefusal.Node 'ID' failed: …error, now branded with the members. This is a non-enumerable symbol brand, thepartial-steps.ts/guard-refusal.tsidiom, so identity, message andinstanceofare unchanged.execute()'s failure exit, theresumeInternalstranded exit,executeWithoutRetry's failure exit, andretryExecution's exhausted exit. The last one reports the last attempt's members, paired with its text.loop/parallelframe rethrows the same object, so the brand survives them.@objectstack/runtimeflow-dispatch-status.ts.flowFailureDetails(result)is one builder, typed asFlowFailureDetails, for the three declared keys.flowFailureEnvelope(result)gives the top-leveluserMessage.domains/automation.ts),/actions(action-execution.ts, plus its handler indomains/actions.ts) and declared endpoint (endpoint-executor.ts). Each door builds itsFLOW_FAILEDdetails through the shared builder and putsuserMessageat the envelope's top level (ApiErrorSchema.userMessage).FlowActionRunDetailsis now the spec'sFlowFailureDetails, so a fourth key fails the build.FlowActionRefusalcarriesuserMessageas a field, which is the channeldeclaredUserMessagereads.deps.errorslot.DomainHandlerDeps.errorgains an optional fourth argument,extra: { userMessage? }(domain-handler-registry.ts), andhttp-dispatcher.tswires it to the existingextraof the envelope builder. A call without it builds the same envelope as before.resume_run(domains/mcp.ts).resumeDoorErrorpasses the slot and copies the envelope'suserMessageonto the thrown error.details.innerRefusalalready travels withdetails.Why
Clause-②: yes (widening), not the claim'snoThe claim said
nobecause #24996 already declared the wire members, and it asked me to re-declare if the built declaration closure showed a key beyondFlowFailureDetailsSchema. Measured on the builtdist/index.d.tsof both packages (built at the head this PR cites), two published types widen:@objectstack/runtime'sDomainHandlerDeps.errorgains the optionalextraargument (the slot the ruling names);@objectstack/service-automation's exportedNodeExecutionResultgains optionaluserMessage/innerRefusal.Both are additive and optional. No export is added. The wire members are the ones #24996 declared. The changeset is
patchfor both packages, with the same two declaration lines.Tests
New pins, each asserting
code+statuson refusal cases:packages/services/service-automation/src/flow-inner-refusal.test.ts(13 tests). A realAutomationEngineand the real CRUD executors run over a data double that throws the real classes (DuplicateRecordError,ValidationError,PermissionDeniedError) after accepting the call through the engine's dispatch predicates.UNIQUE_VIOLATION, while in-flow staysDUPLICATE_RECORD. Validation:VALIDATION_FAILED. Permission:PERMISSION_DENIED.userMessagearrives verbatim, withRESOURCE_CONFLICTfor its unregistered code.userMessagekey. A fault and a declared 5xx carry neither member.erroris asserted byte-for-byte everywhere.packages/runtime/src/flow-failed-inner-refusal.test.ts(10 tests). Covers trigger, resume,/actionsand endpoint.details.innerRefusalparses throughFlowFailureDetailsSchema, and the error parses throughApiErrorSchema.userMessageis top-level only, never a details key.messagekeeps each door's wording.packages/runtime/src/mcp-resume-run.test.ts: one appended test. The MCPresume_runbridge carriesinnerRefusalanduserMessageexactly as the REST resume door does.packages/verify/src/automation-flow-inner-refusal.test.ts(9 tests). This runs end to end: real engine and CRUD executors, the realHttpDispatchertrigger and resume doors, and the real error classes.codeis compared with what the data door'serrorFromThrownanswers for the same thrown value. This is door-against-door parity with the record save.The existing wording pins are untouched and green, including
create-record-duplicate-code.test.ts,try-catch-returned-failure-steps.test.ts,actions-flow-dispatch-status.test.ts(its exact-equality details pin still describes a run that met no refusal),automation-resume-stranded-details.test.ts,automation-trigger-route-status.test.tsandendpoint-flow-dispatch-status.test.ts.Local readings
These are on a shared box, so wall times are contention readings.
Final head
0a2d128cd. The targeted re-run is green:try-catch-returned-failure-steps.test.ts17/17;mcp-resume-run, and the four door suites);Full suites at
bbca8a681:@objectstack/service-automation: 198 files / 2618 tests passed;@objectstack/runtime --project local: 433 files / 6805 passed, 19 skipped.The difference from the final head is a changeset, the service-automation test file above (re-run green at the final head),
scripts/engine-double-contract.pinned.json, and a main merge that touches only driver-sql tests.pnpm --filter … typecheckfor service-automation, runtime and verify: exit 0. That coverstsc --noEmitand each package's test-typecheck ratchet.Gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 80 commands at0a2d128cd. All 80 ran after the final commit and exited 0, with each exit code captured before any pipe.--ranreconciliation: "80 derived famil(ies) accounted for — 80 run, 0 NOT-MEASURED (a DERIVED zero …)". On an earlier head,check-plugin-teardown-shape --self-testneeded its pinned fixture commit fetched into this shallow clone.check:engine-double-contractasked for the new test's double to be pinned, which is done with--write.test:repoand repo-widepnpm lintwere not run locally; both are CI's.Ablations
Each ablation went through
scripts/ablation-replace.mjs, which wraps the mutation, proves it landed on disk, restores it and proves the restore by HEAD blob hash.git diff HEADwas empty after every leg. Every leg turned the pins red:isRefusalforced falseuserMessageread droppedexecute()exit spread droppedinnerRefusaldroppeduserMessagedroppeddeps.errorslot dropped/actionshandler slot droppeddist/(SA1's mutation, rebuilt)ablation-dist-preflightshowed the marker present indist/index.jsanddist/index.cjs. After restoring and rebuilding, the marker was absent and the tree was clean, and the suite was green again at 9 of 9.Acceptance notes
http-dispatcher.tsanddomain-handler-registry.tsare where thedeps.errorslot is defined and wired.domains/actions.tsis where/actionsturnsFlowActionRefusalinto the envelope (one argument).scripts/engine-double-contract.pinned.jsonrecords the new test's pinned double, written by the gate's--write.packages/verify/src/automation-flow-inner-refusal.test.ts:@objectstack/verifyis the one package depending on both the engine and the doors, so it is where the end-to-end pin lives.try_catchwith no catch region, andsubflowon both legs (synchronous, and the resume delegation arm). The run then carries no member, which the spec's "present only when a refusal was met" allows. Carrier: none.codebefore any data call. They are not catch arms, and the ruling names the four catch arms. Carrier: none.run_action. Its tool error still carries no run details, as before (A flow ACTION that fails cannot carry the author'serrorMessageor the runsummary— the/actionsdoor has no channel for them #9585 scoped the details to/actions). The refusal'suserMessagerides the thrownFlowActionRefusalfield.packages/mcp's tool-error renderer shows code, message, status and details, but notuserMessage, on either bridge. Carrier: none.NODE_FAILUREfor a returned node failure, unchanged.Generated by Claude Code