Skip to content

A screen flow whose write node is refused shows the end user the engine's raw node text, in English, with the node id and the object API name ("Node 'create_account' failed: create_record(crm_account) failed: …") #24914

Open

Description

@objectstack-fleet

Ruled: 6303788292 · letter B′ · 2026-10-20T23:12Z

Blocked-by: #24996

Filing gate ① a product defect with reach measured. Measured for objectstack-ai/hotcrm#2073 item 8 (PR objectstack-ai/hotcrm#2203) by its dev, on HotCRM with @objectstack/* 18.0.0-next.5, as a sales_rep in zh-CN, in Chromium at 1440×900 on a locally booted install with demo staffing. The reading was the same before and after the PR. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.

Reader (who acts): objectstack triage. A console half is named below for routing.

The finding, as measured

  • Where: HotCRM's Convert Lead screen flow (lead_conversion). A rep converts a lead whose company exactly equals an account the rep cannot read. The flow's create_account node (create_record on crm_account) is refused by the per-organization unique account name.

  • Expected: a refused write in a user-facing screen flow reaches the user as a sentence they can act on, in their locale, the way a refused record save does in the console. At least it should carry no internal identifiers.

  • Actual: the dialog body and the toast both show, in English under zh-CN:

    Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': a unique constraint already holds these values. No record was written.

    The run answers 400 FLOW_FAILED, and nothing is written.

  • class: a. Reach: any screen flow, in any app, whose write node the engine refuses: unique constraints, validation rules, permission refusals. In HotCRM the measured case is lead conversion; other screen flows with a write node behind the screen are not measured.

Seat review

  • Landing (objectstack): service-automation's failed-node error. It wraps the driver or engine refusal in Node '<id>' failed: <op>(<object>) failed: … and surfaces it verbatim on the FLOW_FAILED envelope. The inner refusal already reads as a user sentence, but the wrapper adds the node id, the operation and the object API name, and none of it is localized.
  • Console half (objectui, for triage to route): the flow runner prints the envelope's message as the dialog body and the toast, with no mapping from a known refusal code to a localized sentence.
  • Not this card: a flow author's own refusal through a refusing end node (outcome: 'refused', objectstack#15788) already reaches the user as authored, and HotCRM's conversion refusals use it. This card is about an engine refusal that no author wrote.

Dedupe

Method: a grep of every objectstack and objectui issue title (the repo-scoped issues?state=all listings, plus the issues since 2026-10-20) for node … fail, failed node, FLOW_FAILED, flow error, raw error, create_record … fail and error … toast / locale / translat.

  • 25 hits. The nearest:

    • objectstack#12975: the /data door ships the CODE: prefix inside a user-facing error, closed;
    • objectui#12977: the organization error map lacks a row, closed;
    • objectstack#17265: a hook's refusal on a script action answers 500 with no toast, closed;
    • objectstack#15788: author refusals on a flow's end node, closed.

    None covers an engine refusal inside a flow node reaching the user raw. New.

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 14 · 2026-10-20T16:58Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24914-flow-node-refusal-readings (a readings-only dispatch: no edit, no PR; the readings are posted to this card)
    Worktree: objectstack-issue-24914 (read-only, off origin/main, for running probes; nothing committed)
    Domain: domain:services
    Seat: domain:services#1
    File surface: none written. Read: packages/services/service-automation/src/builtin/crud-nodes.ts (the create_record / update_record / delete_record / get_record wrappers, ~:542–855), service-automation/src/engine.ts (Node '<id>' failed: at ~:12168 and what the run result keeps of the inner error), packages/runtime/src/domains/automation.ts and flow-dispatch-status.ts (the 400 FLOW_FAILED envelope and its details, under the maintainer rulings on #9378 and #9446), the spec's automation-api.zod.ts and error-code-ledger.zod.ts for what the envelope's shape promises, and the console's flow runner in /home/user/objectui for how it renders the envelope (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default (opus), quoting this dispatch's --tier output "no path-derived mandate … floor sonnet · default opus · ceiling fable"; a readings-only dispatch whose output is a measured route map
    Clause-②: no
    Responsibility: service-automation's node wrappers and engine prefix the engine's own refusal with the node id, the operation and the object API name, and the runtime surfaces that text verbatim as FLOW_FAILED's message | a refused record save already reaches the console as a code the console localises (the card's expected behaviour) | every end user of a screen flow whose write node the engine refuses; measured in HotCRM's lead conversion
    Thread-read: none
    Serial constraints cleared: Nothing is written. Of the 14 open PRs (all files read at this claim), PR #24778 (the spec seat's #24690, open draft) touches service-automation/src/builtin/crud-nodes.ts and crud-dropped-fields.test.ts. The readings name it. A later code dispatch on crud-nodes.ts serialises behind it. No open PR touches the runtime's automation and flow-dispatch files, and neither services seat has an in-flight claim naming them.

    Why readings first. The fix spans:

    Whether carrying the inner refusal's code is a protocol-class change is a reading, not a guess. The seat rules the route from these readings. A dispatch for code follows only then.

    Readings taken on origin/main 71696121a6.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24914,
    "status": "reading",
    "branch": "claude/issue-24914-flow-node-refusal-readings",
    "pr": null,
    "session": "session_011TdZ8GjixNZFLz1xJD9zzK - the dispatching Claim 6296417944 names it; this run is its subagent",
    "premise_still_valid": true,
    "summary": "READINGS ONLY on objectstack origin/main 7169612 (the seat's read; unchanged at this run). No source edit, commit, push or PR. Identity: the newest and only Claim on #24914, 6296417944, names branch claude/issue-24914-flow-node-refusal-readings. CONFLICT NAMED, not silently resolved: the dispatch asks for a probe on a real engine under the verify lock and status done; .claude/agents/os-dev.md says a readings-only dispatch builds no closure, runs no tests and reports status reading, and that file wins over a dispatch. So nothing was built or run. The measured legs below are the card's own public-door measurement carried to main by byte identity, plus CI conclusions on code-identical main commits; the validation and permission legs are static readings. PREMISE: valid for the defect, with two corrections. (1) The inner refusal is not clean: DuplicateRecordError's own sentence names the object API name (objectql/src/duplicate-record-error.ts:131-138, 'Duplicate record refused on OBJECT: ...'), so removing the wrappers alone cannot meet the card's 'no internal identifiers'. (2) A refused record save is localised by code only for permission refusals; a unique violation on a record save shows the same English inner sentence (Q4). Q1 CHAIN, the card's case (unique refusal in create_record, reached through the resume door because create_account runs after screen_1). Hop 1, objectql: engine.insert throws DuplicateRecordError {code DUPLICATE_RECORD, status 409, object, field when determinable, developerMessage, cause} (duplicate-record-error.ts:82-121). Hop 2, service-automation/src/builtin/crud-nodes.ts:657-688: the catch returns {success:false, error:'create_record(OBJECT) failed: ' + message, code}, keeping code ONLY when it equals DUPLICATE_RECORD (:680-683); status, field, developerMessage, cause and any userMessage die here. Siblings get_record :542, update_record :788, delete_record :855 keep the message only, no code. Hop 3, engine.ts returned-failure arm: the step log records error {code:'NODE_FAILURE', message} (:12107, the executor's code replaced), the in-flow $error gets {nodeId, message, output, code?} (:12145-12150, the only place the code survives), then throw new Error("Node 'NODE_ID' failed: " + errMsg) (:12168), a plain Error. Hop 4, terminal exits: execute() catch :7104 takes the thrown text into the run log error (:7156) and the result {success:false, error, status:'failed', errorMessage: flow.errorMessage, flowLabel, summary} (:7254-7311); resumeInternal :8610 -> :8690 -> result status 'stranded' (:8732-8771). No code and no inner message survive as fields. Hop 5, doors: classifyFlowRefusal takes message = result.error (runtime/src/flow-dispatch-status.ts:203) and the 400 FLOW_FAILED row (:236); trigger door domains/automation.ts:2030-2045 answers deps.error(message, 400, {code, errorMessage?, summary?}); resume door classifyResumeResult :2339-2361 answers message result.error (:2353) with details {code FLOW_FAILED, errorMessage?, summary?, runId, repairable, status?}; /actions prefixes a third time, "Flow 'TARGET' failed: " + result.error (action-execution.ts:1288-1296, served at domains/actions.ts:826-831); declared endpoints endpoint-executor.ts:581-598. On the wire the only structured trace of the failure is details.summary.nodes[] {nodeId, nodeType, status failure}, which already names the failing node. Q1 MEASURED: HotCRM's reading in the card body (18.0.0-next.5) is exactly the string this chain composes at the resume door: crud-nodes.ts, flow-dispatch-status.ts and duplicate-record-error.ts are byte-identical between the 18.0.0-next.5 tag commit 820fd5a and main (diff empty), and the engine throw line and the resume door's message line are present verbatim on both. Validation leg (static): ValidationError (objectql/src/validation/record-validator.ts:241-257; code VALIDATION_FAILED, message = the joined author rule messages, fields[]) loses code and fields[] at hop 2, so the user reads "Node 'N' failed: create_record(OBJ) failed: RULE MESSAGE". Permission leg (static): PermissionDeniedError (plugins/plugin-security/src/errors.ts:41-58; PERMISSION_DENIED, 403, details) loses all three, so the user reads '... failed: [Security] Access denied: ...', operator text the record form deliberately replaces. App-hook leg (named producer): HotCRM src/sales/objects/account.hook.ts refuse() sets userMessage on crm_account beforeInsert refusals (10 HotCRM hook files mark userMessage, crm_account, crm_contact and crm_opportunity among them); create_account writes crm_account, and hop 2 drops the marking, so a producer-marked refusal reaches the flow user unmarked and prefixed. Q2 ENVELOPE: FLOW_FAILED is registered under @objectstack/runtime (spec api/error-code-ledger.zod.ts:421-426). ApiErrorSchema (api/contract.zod.ts:16-97): code closed; declaredCode means demotion of a non-vocabulary code (:30-49); message 'Readable error message' (:50); userMessage is producer-marked end-user text, verbatim, which 'never replaces message - the diagnostic channel keeps its own wording' (:51-97). details is z.unknown() (contract.zod.ts:253, errors.zod.ts:450). The resume door's details are declared as ResumeFailureDetailsSchema (api/automation-api.zod.ts:390-470) beside errorMessage/summary. AutomationResult (contracts/automation-service.ts:387-589): code is a CLOSED union of trigger and resume refusal codes (:489); errorMessage is the flow's friendly failure text (:543-549). Rulings, quoted: #9378 comment 5321043983 (maintainer verbatim 「同意」): 'ran and failed ... 400 FLOW_FAILED' and 'codes are stamped at the route ...; AutomationResult.code stays closed - the engine classifies via existing vocabulary; any genuinely NEW ledger code this surfaces goes through the spec seat, never minted mid-PR. No sniffing - producer classifies, route maps.' #9384 comment 5321043167 (「同意」): 'The union stays closed'. #9446 comment 5322867819 (「同意」): 'the #9378 status table is a property of the flow-dispatch CONTRACT - all three doors converge'. #9585 (「接受你的所有建议。」, quoted in claim 5339708379): the /actions door 'carries exactly two fields into error.details ... errorMessage ... and summary' and 'Scope: bounded to the one door. No new schema keys.' (restated at action-execution.ts:1050-1051: 'A third key here is a contract widening the #9585 ruling does not cover'). #22507 comment 6093472708 (「22507 同意」), restated at engine.ts:11762-11764: 'a user-read flow string the server renders per run is translated where it is rendered, in the run's locale, before its holes are filled, and a client overlay never touches a server-rendered slot.' Unique wire spelling, maintainer ruling 2026-09-03 recorded in commit 65846bc (runtime/src/http-dispatcher.ts:1204-1207): 'one wire spelling on every route, UNIQUE_VIOLATION'. userMessage: maintainer ruling 2026-08-19 on objectui#5210, option 1 (contract.zod.ts:59-61; scope restated in objectui#5210 comment 5344182262: 'render a marked ... message; an unmarked message keeps the generic substitution'). Q2 SLOT ANSWER: no existing slot carries the inner CODE without a contract act. details parses any key, but the FLOW_FAILED details are ruled per door (#9585: exactly two at /actions; ResumeFailureDetailsSchema at resume) and pinned by exact equality (runtime/src/domains/automation-resume-stranded-details.test.ts:99, runtime/src/actions-flow-dispatch-status.test.ts:377); declaredCode means demotion and every inner code here is a vocabulary member; AutomationResult.code is closed; summary.nodes[] declares no code. The inner TEXT does have an existing declared slot, ApiErrorSchema.userMessage, which no flow door emits today. Q3 CONSUMERS. Diagnostic (want node id, operation, object): sys_automation_run.error (service-automation/src/sys-automation-run.object.ts:375-380, 'the message a designer needs to fix it'), written by recordLog at engine.ts:7156 and :8690 and the retry exits (:13645, :13678); Studio's FlowRunsPanel reads run.error and step.error (objectui previews/FlowRunsPanel.tsx:403, :315); the step log (engine.ts:12107); in-flow $error.message and NODE_ID.error (engine.ts:12145-12151, setNodeError :11718-11723), which fault handlers and try_catch catch regions read, documented as 'normally names the step that failed' (spec automation/control-flow.zod.ts:342); the subflow parent text (engine.ts:8059) and failAncestors (:8700); plugin-approvals composes its resume failure and stranded refusal from result.error (plugins/plugin-approvals/src/approval-service.ts:4213, :4464); SDK, MCP and integration callers read error.message (MCP resume_run domains/mcp.ts:1003; run_action through dispatchFlowAction). The run-summary log line carries counts, not the error text (engine.ts:10452-10470). End-user facing: only the objectui FlowRunner dialog Alert and toast, and the two flow-launch handlers, all through interpretFlowResponse. Pins on the PRODUCED wording against a real engine or stack: packages/qa/dogfood/test/flow-runas.dogfood.test.ts:137 (wire message contains "Node 'ID' failed"), service-automation try-catch-returned-failure-steps.test.ts:314 and :346, loop-dying-body-steps.test.ts:334, builtin/create-record-duplicate-code.test.ts:96. Door suites that pass a fixture string through a fake service: automation-trigger-route-status.test.ts:103 and :142, actions-flow-dispatch-status.test.ts:199, :364, :481, endpoint-flow-dispatch-status.test.ts:130, http-dispatcher.test.ts:601, client.test.ts:1542 and :1614, envelope-convergence.test.ts:195, rest-approvals-wire-codes.test.ts:381. Q4 CONSOLE (objectui origin/main 878a4af; FlowRunner.tsx, flowResponse.ts, core actionErrorDetail.ts, react error-message.ts and data-objectstack are absent from the 145-file compare to remote main 8cd4db4, so byte-identical there; form.tsx is in it and was read at 8cd4db4). Runner: interpretFlowResponse's 400 FLOW_FAILED arm (app-shell/src/utils/flowResponse.ts:345-355) shows details.errorMessage, translated through flows.FLOW.errorMessage (:212-226, :296-300), else actionErrorDetail(json), i.e. error.message verbatim (core/src/actions/actionErrorDetail.ts:27-35). It reads neither userMessage nor any inner code. FlowRunner shows that string in toast.error and a destructive Alert (views/FlowRunner.tsx:460-466, :627-633), under a comment calling error.message 'already user-grade prose ... passed through verbatim and untranslated' (:629-632). Record save (components/src/renderers/form/form.tsx:3023-3026 at 8cd4db4): declaredUserMessage(error), else the permission substitution t('form.noPermissionToSave') when isPermissionError, else extractWriteErrorMessage(error) (strips one leading TAG or CODE prefix), else t('form.submitFailed') (react/src/utils/error-message.ts:125-137, :202-215, :52-90). So the only code-keyed localisation of a refused save is PERMISSION_DENIED/FORBIDDEN/403; a unique violation shows the server's English sentence naming 'crm_account', since the data door keeps message as thrown (runtime/src/http-dispatcher.ts:1228-1233). git grep at 878a4af: zero non-test hits for UNIQUE_VIOLATION or DUPLICATE_RECORD in packages//src and apps//src. Would a code on the envelope be enough? No: it needs a server carrier (none exists, Q2), a new reader in the FLOW_FAILED arm, new copy per code in 10 locale files (copy that record saves do not have either), and a wire-spelling choice. Handing the runner the inner refusal and reusing the record-save rule needs no new copy and gives record-save parity. Q5 PR #24778 (draft, head 56cac78): its crud-nodes.ts hunk is at :153-170 (DROPPED_REASON_LABEL gains stored_expression; droppedFieldsWarning branches on it), the success-path warning. The four catch arms (:542, :657-688, :788, :855) are untouched, so no textual or semantic collision with any route here; a route editing the catch arms is a disjoint hunk in the same file and only the file-level serial constraint the claim records applies. Its spec files (contracts/data-engine.ts, data/data-engine.zod.ts) and objectql files (engine.ts, readonly-strict-errors.ts) are disjoint from every route's files. CORPUS for the business axis: HotCRM main 3116e7ac3a has 6 screen flows with write nodes (lead_conversion, quote_generation, schedule_followup, escalate_case, close_case, claim_case; 13 write nodes), 0 declaring errorMessage, 0 try_catch nodes or fault edges. objectstack examples at 7169612: app-crm crm_convert_lead_wizard declares errorMessage with a zh-CN translation (examples/app-crm/src/translations/crm.translation.ts:219); app-showcase showcase_reassign_wizard (1 update_record) and app-todo quick_add_task (1 create_record) declare none. So the unauthored default is what users see on 8 of the 9 write-bearing screen flows measured. ROUTE MAP and recommendation in open_questions[0]: recommend B-prime (carry the inner refusal across the flow door, message unchanged, the console renders it with its record-save rule), with HotCRM's app half (F) in parallel and the inner sentence's API name as an engine-lane sub-issue. Lanes: domain:spec, domain:services, domain:cli, domain:ui (objectui), domain:engine for the inner sentence, repo:hotcrm for the app half.",
    "tests": "NO test suite, build or probe script was run (os-dev.md readings clause; conflict named in summary). Real-engine probe for the unique, validation and permission legs: NOT MEASURED, reason: readings-only dispatch, no closure build. Readings taken instead, each a count or diff against a named tree: (1) Byte identity of the chain between the 18.0.0-next.5 tag commit 820fd5a (refs/tags/@objectstack/cli@18.0.0-next.5) and main 7169612, files fetched by REST contents at that ref: diff of crud-nodes.ts, flow-dispatch-status.ts and duplicate-record-error.ts printed nothing; the throw line "throw new Error(Node '${node.id}' failed: ${errMsg})" is at next.5 engine.ts:12008 and main :12168; "message: result.error ?? 'Flow run failed'" is at next.5 domains/automation.ts:2347 and main :2353. This carries the card's HotCRM public-door measurement onto main's chain. (2) git diff --stat 74fcec1..7169612: 3 files (content/docs/releases/v18/index.mdx, content/docs/upgrading.mdx, packages/cli/CHANGELOG.md). git diff --stat d606fc1..7169612 limited to service-automation, runtime, objectql, qa, spec, types: empty (the 6-file delta is docs, CHANGELOG and driver-sql introspection). (3) CI check-runs read by REST: d606fc1, Test Core (1/6) through (6/6) success in one run and Dogfood Regression Gate (1/3) through (3/3) success; 74fcec1, Dogfood Regression Gate (1/3) through (3/3) success. So the real-stack pin flow-runas.dogfood.test.ts:137 (a refusal inside a flow write node answers 400 FLOW_FAILED whose message contains "Node 'ID' failed") and the real-engine pins try-catch-returned-failure-steps.test.ts:314 and create-record-duplicate-code.test.ts:96 hold on code identical to main. Main head 7169612's own full battery was in_progress at read time: NOT MEASURED on that sha. (4) Corpus counts: HotCRM at 3116e7ac3a (blobless clone into the scratchpad, files read with git show, nothing executed) and objectstack examples at 7169612, as reported in summary. (5) objectui: REST compare 878a4af...8cd4db4 = ahead 20, 145 files; the console chain files are absent except form.tsx, whose catch branch is unchanged (remote :3023-3026). git grep at 878a4af for UNIQUE_VIOLATION or DUPLICATE_RECORD in non-test packages//src and apps//src: 0 hits. (6) PR #24778 diff by REST: the crud-nodes.ts hunk header is '@@ -153,10 +153,21 @@'. Ablation and reverse verification: not applicable, nothing changed.",
    "gates": "No diff, so no gate family is derived and none was run; there is no PR, so there are no PR check-runs to cite. CI readings on main, cited not re-run: d606fc1 Test Core 1/6-6/6 success, Dogfood Regression Gate 1/3-3/3 success, Build Core success; 74fcec1 Dogfood Regression Gate 1/3-3/3 success, TypeScript Type Check success; 7169612 Test Core 1/6, 5/6, 6/6 and Dogfood Regression Gate in_progress at read time. Worktree ../objectstack-issue-24914 was created off origin/main 7169612, never edited, and removed at the end after git status --porcelain and git diff HEAD both printed nothing; the local branch ref, which pointed at 7169612 with no commit of its own, was deleted with it.",
    "mcp_calls": "0 - no MCP GitHub tool was called",
    "api_writes": "1 - POST /repos//issues/24914/comments (this os-dev-report, through scripts/pm/post-stamped.mjs and the fleet relay). Every other GitHub call was a REST read.",
    "open_questions": [
    {
    "question": "Which route fixes the engine refusal text a screen-flow user sees? Routes are mapped with files, lanes, protocol class and cost; every reading is on objectstack 7169612 and objectui 878a4af/8cd4db4.",
    "options": [
    "A - server only, rewrite error.message. The envelope message becomes the inner sentence; node id, operation and object move to details or the run record. Needs a new AutomationResult member for the inner sentence (spec contracts/automation-service.ts plus TriggerFlowResponseSchema parity), engine carriage (returned-failure arm :12097-12168, execute :7104-7311, resumeInternal :8610-8771, retry exits :13613-13681), crud-nodes four catch arms, and four doors plus the MCP bridges (flow-dispatch-status.ts:203, automation.ts:2030-2045 and :2339-2361, action-execution.ts:1288-1296, endpoint-executor.ts:581-598). Protocol class: yes, a REST door's answer changes and a spec member is added. Cost: three lanes (spec, services, cli); pin churn in flow-runas.dogfood.test.ts:137, four runtime door suites and two client suites; the /actions wording has been on the wire since #3962 and its comment calls re-labelling it unruled. Four axes: business - removes both prefixes for the card's case, but the user still reads 'Duplicate record refused on crm_account', English, and for a permission refusal reads raw '[Security] Access denied', which the record form substitutes; long-term - contradicts ApiErrorSchema's split, where message is the diagnostic channel and userMessage the user channel, takes the operation and object off the wire for SDK and MCP callers, and makes message a server-rendered user-read string that #22507 ruling A says must then be translated server-side, which no catalog does; AI - neutral; startup - three lanes for a partial fix.",
    "B - A plus the inner code on the envelope. AutomationResult.code is closed by the #9378 and #9384 rulings, so a new member is needed; an envelope details key widens the #9585 two-field bound and the exact-equality pins; the console needs a reader and new copy per code in 10 locales; the code needs a wire spelling (UNIQUE_VIOLATION per the 2026-09-03 ruling, not the in-process DUPLICATE_RECORD). Protocol class: yes. Cost: everything in A plus objectui and a ruling. Four axes: business - zero measured consumers of a flow-envelope code today (the console localises no UNIQUE_VIOLATION even on record saves); long-term - inherits A's narrowing of message; AI - code-branching beats regex; startup - largest route, with new copy that has no pull.",
    "C - console only, map refusal codes in the flow runner. Not possible today: no code reaches the console (error.code is FLOW_FAILED, details carry errorMessage, summary and the resume verdict, summary.nodes[] has no code); reading one out of the message is a regex over prose, which PD #12 and objectui's one-strict-contract rule forbid. Protocol class: none. Four axes: fails business (nothing to map) and long-term (sniffing).",
    "D - carry the producer-marked userMessage across the flow door (other route; the minimal subset of B-prime). crud-nodes' four catch arms read declaredUserMessage(err) (@objectstack/types, already a dependency of service-automation); NodeExecutionResult (service-automation engine.ts:358) and AutomationResult (spec) gain an optional userMessage with ApiErrorSchema's semantics; the four doors emit it as the envelope's existing top-level userMessage, which needs deps.error to take it (runtime domain-handler-registry.ts:313 has no slot); objectui's FLOW_FAILED arm reads it after errorMessage and before message. message is unchanged, so no wording pin moves. Protocol class: yes, additive (Clause-2 yes, widening). Lanes: spec, services, cli, ui. Four axes: business - measured producers exist (10 HotCRM hook files mark userMessage, including crm_account, which the card's node writes), but the card's measured unique refusal is unmarked and is NOT changed; long-term - completes a declared channel at the one boundary that drops it, which is the frame's 'declared but not delivered = implement' rule; AI - removes a silent drop of a marking AI-written hooks already make; startup - smallest server route with measured producers.",
    "B-prime - carry the inner refusal structurally, message unchanged (other route; contains D). D, plus the inner refusal as {code in its wire spelling, message} in ONE FLOW_FAILED details member declared once in spec (the ResumeFailureDetailsSchema pattern) and emitted by all four doors; objectui's FLOW_FAILED arm renders it with the record-save rule it already has (form.tsx:3023-3026: userMessage, then the permission substitution, then the inner message with its prefix stripped), after the author's errorMessage. Needs a maintainer ruling, since it widens the #9585 two-field details at /actions and the flow-dispatch details at the other doors. Protocol class: yes, additive (Clause-2 yes, widening). Lanes: domain:spec (the AutomationResult member and the details declaration), domain:services (crud-nodes four catch arms, engine carriage on both terminal exits and the retry exits; optionally stamp the executor's code into the step log instead of NODE_FAILURE), domain:cli (trigger, resume, /actions and endpoint doors, MCP bridges, deps.error), domain:ui (flowResponse.ts and the FlowRunner comment). Four axes: business - the only server route that brings all three classes the card names to record-save parity (unique: the unprefixed inner sentence; validation: the author's rule text; permission: the localised substitute; hook: the marked text), on every unauthored screen flow (8 of the 9 write-bearing screen flows measured); locale for the unique case still needs F or the engine sub-issue, exactly as on a record save; long-term - fixes the root, which is flattening the structured refusal into a string at hop 2, once for all doors, keeps message diagnostic for logs, SDK, approvals and the stored run, and moves zero wording pins; AI - one declared carrier with no consumer-side regex, and AI-written flows get record-save behaviour without authoring anything; startup - the largest of the recommended set (4 lanes and a ruling), but it adds no error code, no gate and no copy.",
    "E - console only, generic substitution (other route). When no errorMessage and no userMessage reach the runner, show a localised generic sentence and keep the raw text for the developer console, as the record form does for permission refusals. Protocol class: none. Lane: domain:ui. Four axes: business - no identifiers and localised, but it hides the author's validation rule text and hook text, so the user can no longer act on the reason; long-term - matches the 'unmarked is diagnostic' clause of ApiErrorSchema but overrides producers (ValidationError, DuplicateRecordError) that write message for display; AI - safe default; startup - smallest.",
    "F - app half in HotCRM, no platform change (other route, independent of the choice above). Declare errorMessage, with a zh-CN translation at flows.lead_conversion.errorMessage, on its 6 write-bearing screen flows, and/or route $error.code == 'DUPLICATE_RECORD' on create_account (available on create_record today) to a refusing end node whose message is translated at its flows refusals key and picked by the engine in the run's locale. Fixes the measured case in the rep's locale with shipped features. Carrier: the repo:hotcrm seat."
    ],
    "recommendation": "B-prime, with F in parallel and the inner sentence as an engine-lane sub-issue (out_of_scope_findings[0]). Business: B-prime is the only server route that removes the node id and operation from what the user sees for all three named refusal classes without narrowing message, and the default it fixes is the one 8 of the 9 measured write-bearing screen flows rely on; F is the only route that gets the measured case into zh-CN now. Long-term: it repairs the flattening at its source and honours ApiErrorSchema's message/userMessage split rather than rewriting the diagnostic channel (A, B). AI: one declared carrier, no regex (C), no override of producer text (E). Startup: no new code, no gate, no copy; the cost is four lanes and one ruling, which the measured producers and the 8-of-9 default justify. The four axes conflict on cost: if the maintainer wants the narrowest act, D alone completes the declared userMessage channel and is a strict subset of B-prime, but it leaves the card's measured unique refusal unchanged, so it does not close this card. No new gate is recommended (a lint for screen flows without errorMessage would be a new gate, which defaults to no and no maintainer has named one)."
    },
    {
    "question": "Under B-prime, which spelling does the carried inner code take for a unique refusal?",
    "options": [
    "A - UNIQUE_VIOLATION, the wire spelling the 2026-09-03 ruling fixes for every route (runtime/src/http-dispatcher.ts:1204-1207); the flow door translates the engine's class the way the data doors do.",
    "B - DUPLICATE_RECORD, the in-process identity the crud node already carries to $error.code (crud-nodes.ts:680-683)."
    ],
    "recommendation": "A. Business: the console's record-save rule and any client already branch on the data door's wire codes, so parity means the same spelling. Long-term: the ruling says one wire spelling on every route, and B would add a second. AI: one spelling per boundary is what DuplicateRecordError's developerMessage teaches. Startup: no extra vocabulary. In-flow $error.code stays DUPLICATE_RECORD, which the ruling leaves in-process."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: measured public door - HotCRM's FlowRunner text on 18.0.0-next.5 (card body) carries the inner sentence 'Duplicate record refused on crm_account: ...', byte-identical producer on main; named producer buildDuplicateMessage, packages/objectql/src/duplicate-record-error.ts:131-138, whose message http-dispatcher.ts:1228-1233 keeps as thrown on the data door, so a record save shows it too (that leg NOT MEASURED, read statically through form.tsx:3023-3026 at objectui 8cd4db4) · evidence: the user-facing sentence of a unique refusal names the object API name, which the card's own bar ('no internal identifiers') rules out on both the flow and record-save surfaces; every flow route in open_questions[0] still shows it until the producer names the object by label or not at all · file as a sub-issue of #24914 in domain:engine (the inner-producer half of the route) · dedupe words: DuplicateRecordError message object name; unique violation API name user; Duplicate record refused on; buildDuplicateMessage label",
    "carrier: none (承接者:无) · noted, not filed: the engine's step log records every returned node failure as code NODE_FAILURE (service-automation/src/engine.ts:12107) even when the executor classified one (create_record's DUPLICATE_RECORD reaches $error.code at :12149 but not the stored step), so the stored run cannot show a classified code; an observation with no reader that misbehaves today, folded into B-prime's optional services item",
    "carrier: repo:hotcrm seat · noted, not filed: route F (errorMessage with zh-CN on the six write-bearing screen flows lead_conversion, quote_generation, schedule_followup, escalate_case, close_case and claim_case; or $error.code routing on create_account to a translated refusing end) is app work with shipped features, recorded so the HotCRM seat can take it"
    ]
    }


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    决策请求 · domain:services seat 1 · os-justin · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T17:30Z

    依据:只读测量报告 6296914512(本卡),读数基于 objectstack origin/main 71696121a6、objectui 878a4af / 8cd4db4。认领 6296417944 的范围是只读,没有写任何代码。

    一句话问题: 用户在 screen flow 里点保存,被平台拒绝(重复记录、校验规则、权限)时,弹窗和提示条显示的是一串英文开发者文本,里面带着节点 id 和对象 API 名。同样的拒绝如果发生在普通记录表单里,用户看到的是能看懂、能照着处理的话。

    背景

    • 用户今天看到的(HotCRM 线索转换,zh-CN 下仍是英文):Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': …
    • 原因: 拒绝在流程里逐层被压扁成一段字符串。
      • 写节点的 catch 只留下 message(crud-nodes.ts:657-688),只有 DUPLICATE_RECORD 一种保留了 code;
      • 引擎再加一层 Node '…' failed: 前缀(engine.ts:12168);
      • 三个流程门把这段字符串原样当作 FLOW_FAILED 的 message(flow-dispatch-status.ts:203、automation.ts:2353、action-execution.ts:1288-1296);
      • 控制台 FlowRunner 再原样显示(objectui flowResponse.ts:345-355)。
    • 记录表单为什么不同: 那条路径先看生产方标记的 userMessage,权限拒绝替换成本地化句子,其余去掉一层前缀(objectui form.tsx:3023-3026)。
    • flow 门的现状:
      • 不发 userMessage;
      • details 由裁决定死(/actions 门恰好两个键);
      • AutomationResult.code 是闭合联合;
      • 所以内层拒绝没有现成的承载位。

    Governing text

    协议声明、是否改协议

    推荐的路线要改协议:在 AutomationResult 上加一个可选成员,并给三门的 FLOW_FAILED details 声明一个新成员。这是加宽,按 Clause-②: yes (widening) 声明。它放宽了 #9585 的两键上限,所以按定级判据属协议类,由维护者裁决。

    前提(各带 re-check 命令)

    1. 写节点 catch 丢掉内层的 code、userMessage 和 details。re-check:git grep -n "failed: \${(err as Error).message}" packages/services/service-automation/src/builtin/crud-nodes.ts,应命中 4 处。
    2. flow 门的 FLOW_FAILED details 不带内层 code。re-check:git grep -n "the #9585 ruling does not cover" packages/runtime/src/action-execution.ts(应命中 1 处,:1050)
    3. 控制台 FLOW_FAILED 分支不读 userMessage。re-check:在 objectui 里跑 git grep -n userMessage packages/app-shell/src/utils/flowResponse.ts,应为 0 命中。

    选项 × 真实代价 × 工作量

    选项 做什么 客户可感知的后果 工作量
    A 只改服务端 message 把 message 改写成内层句子 前缀没了,但唯一约束拒绝仍点名 crm_account、仍是英文,权限拒绝露出 [Security] Access denied 原文;SDK、MCP、审批侧读到的诊断文本少了节点和操作 spec、services、cli 三车道;无先例
    B A 加内层 code 在 A 的基础上把 code 带上信封,控制台按 code 出文案 同 A,再加每个 code 10 种语言的新文案(记录表单今天也没有这些文案) A 再加 objectui,并需裁 wire 拼写;无先例
    C 只改控制台 在 runner 里按 code 映射 今天做不到:没有 code 到达控制台,只能对文本跑正则 —
    D 只补 userMessage 通道 写节点读出生产方标记的 userMessage,三门作为信封顶层 userMessage 发出,控制台优先显示 带标记的应用 hook 拒绝能原样到达用户(HotCRM 有 10 个 hook 文件做了标记);本卡实测的唯一约束拒绝没有标记,不受影响 spec、services、cli、ui 四车道,加宽;无先例
    B′ 结构化携带内层拒绝(含 D) 在 D 的基础上,把内层拒绝的 {code(wire 拼写), message} 作为 FLOW_FAILED details 的一个成员,在 spec 声明一次、三门都发;控制台沿用记录表单的规则显示 三类拒绝都和记录表单一致:唯一约束显示去掉前缀的内层句子,校验显示作者写的规则文本,权限显示本地化替换句,hook 显示标记文本;message 不变,诊断方不受影响,现有措辞断言一条不动 spec、services、cli、ui 四车道,加一条裁决;无先例
    E 控制台通用替换 没有作者文案时显示一句本地化的通用句 没有内部标识,但用户看不到校验规则和 hook 给出的原因,无从处理 只动 ui;无先例

    业务含义直译:

    • A:把给开发者看的报错单改成给顾客看的,但顾客看到的内容仍然不对。
    • B:A 之上再开一张新的翻译清单,今天没人用它。
    • C:没有原料,做不出来。
    • D:店员已经写好的那张便条,终于能递到顾客手里。
    • B′:流程里的拒绝和柜台上的拒绝说同一种话。
    • E:一律只说「出错了,请稍后再试」。

    四轴(业务立场)

    • 实际业务需求:
      • 实测的 9 个带写节点的 screen flow 里,有 8 个没有作者文案(HotCRM 6 个、showcase 1 个、todo 1 个),它们用的就是这条默认路径。
      • 三类拒绝都有真实生产方:平台的唯一约束、作者的校验规则,以及 HotCRM 带标记的 hook。
      • B′ 是唯一能让这三类都对齐记录表单的服务端路线。
    • 项目长远合理性: B′ 在源头修复「结构化拒绝被压扁成字符串」,且只修一次、三门共用;message 仍是诊断通道,符合 ApiErrorSchema 对两个通道的拆分。A 和 B 反过来改写诊断通道。
    • 防 AI 犯错: 出错时,用户看到可执行的原因,开发者在 run 记录里仍看到节点和操作。B′ 只有一个声明过的承载位,控制台不靠正则(C 只能靠正则);AI 写的流程不用另写任何东西,就得到和记录表单一样的行为。
    • 创业阶段不扩散: B′ 不新增错误码、不新增门禁、不新增文案;代价是四个车道和一条裁决。只要最小的一步,可以选 D,它是 B′ 的严格子集,但关不了本卡实测的唯一约束案例。

    推荐:B′,内层 code 用 UNIQUE_VIOLATION 拼写

    • 终态句: 两年后,流程运行里的任何拒绝,都以和记录保存相同的结构化形态(code + 用户文本 + 诊断文本)到达客户端,客户端用一套规则显示。参照:Salesforce 的 DML 错误本身是结构化的(statusCode、message、fields),记录保存与程序调用读同一个结构;本平台的数据门已经如此,流程门是唯一把它压扁的地方。
    • 置信缺口:
      • 真实引擎上的三类拒绝探针没有跑(只读测量不建闭包)。链路靠 18.0.0-next.5 与 main 的字节一致,承接了卡上 HotCRM 的公开门实测。
      • 内层句子本身点名对象 API 名(duplicate-record-error.ts:131-138),任何路线都要靠一张引擎子卡另行处理。
    • 其余选项不取的理由:
      • A:改写诊断通道,且仍点名 API 名。
      • B:新文案没有需求拉动。
      • C:无料可做。
      • D:关不了本卡。
      • E:遮住了用户能据以处理的原因。
      • 应用侧在 HotCRM 给 6 个流程补 errorMessage:属于应用补位;按「平台相关的功能,应该尽量先改平台」(维护者 2026-10-18,台账 6245607810),席位不推荐它作为本卡的修法。
    • 自检: 只看①选 B′;②③④ 是否翻转:否。

    裁后执行(选 B′ 时)

    1. spec 席位: 在 AutomationResult 加可选成员 userMessage 与内层拒绝成员,并在三门的 FLOW_FAILED details 声明一次(仿 ResumeFailureDetailsSchema)。按 Clause-②: yes (widening) 做契约复核。
    2. 本车道:
    3. domain:cli: trigger、resume、/actions、endpoint 四门与 MCP 桥发出新成员,deps.error 增加 userMessage 槽。在 [PM seat] domain:cli · seat 2 — 🟢 os-justin · session_01LmjU2x1faLZ39DVTFyVca6 #22648 声明。
    4. objectui: FLOW_FAILED 分支依次读作者 errorMessage、userMessage、内层拒绝(套记录表单规则)、message。
    5. 引擎子卡: 唯一约束句不再点名对象 API 名。席位随本决策立卡,见下。

    相关单与 PR

    os-decision-facets

    • ① 项目长远合理性:B′ 收缩特例——拒绝在流程门与数据门以同一结构到达,消除「压扁成字符串」这一处特例;新增一个声明成员,不新增错误码。
    • ② 实际业务拉动:今天撞上的是 HotCRM 线索转换的销售代表;实测 9 个带写节点的 screen flow 中 8 个依赖这条默认路径。
    • ③ 防 AI 犯错:一个声明过的承载位,客户端零正则;出错时用户看到可执行原因,开发者在 run 记录里仍看到节点与操作。
    • ④ 创业阶段不扩散:不新增错误码、门禁与文案;新增的一个 details 成员是永久义务,由 spec 声明一次、三门共用。

    Prior rulings read: FLOW_FAILED details · AutomationResult.code closed · userMessage · UNIQUE_VIOLATION wire → 6 hits (#9378 5321043983, #9384 5321043167, #9446 5322867819, #9585 claim 5339708379, #22507 6093472708, objectui#5210 5344182262); ADR none named by the code comments; thread: none.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    指针 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T17:34Z:决策 6297041774 裁后执行第 5 步的引擎子卡已立为 #24951(唯一约束拒绝句点名对象 API 名,domain:engine 由分诊定级)。它不随本卡的裁决变化。


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #379 item 5 · letter B′ · maintainer 「同意」 2026-10-20T23:06Z

    Director seat, summon #45 continuation 2, session_01Lg6GQ4xjqmvEzYv78d6H7i. Presented in this session's chat as batch #379 item 5, from the decision 6297041774 (domain:services seat 1) and the read-only measurement 6296914512; the director's chat delta re-checked on origin/main e4d604efd39f the four flattening catch arms (crud-nodes.ts, 4 hits of the failed: ${…message} template), action-execution.ts:1050 ("a contract widening the #9585 ruling does not cover") and contract.zod.ts:93 (userMessage), and on objectui origin/main 53dea27afab5 that flowResponse.ts never reads userMessage (0 hits). The maintainer's 「同意」 takes the recommendation, letter B′, with the inner code in the wire spelling (UNIQUE_VIOLATION). Thread read through 6297113220 at this write; nothing newer.

    Governing text: ApiErrorSchema (packages/spec/src/api/contract.zod.ts:50-97: message is the diagnostic channel, userMessage the producer-marked end-user text that never replaces it); #9378 ruling 5321043983 (AutomationResult.code stays closed); #9446 5322867819 (the three doors converge); #9585 (/actions details carry exactly two fields — relaxed by this ruling for one declared member); #22507 6093472708 (server-rendered user text is translated at render in the run's locale); the 2026-09-03 wire-spelling ruling (http-dispatcher.ts:1204-1207, UNIQUE_VIOLATION on every route).

    What the ruling binds

    1. B′ — the inner refusal travels structurally, message unchanged. AutomationResult gains an optional userMessage and an optional inner-refusal member { code, message } (code in its wire spelling: UNIQUE_VIOLATION for a unique refusal; in-flow $error.code stays DUPLICATE_RECORD); the trigger, resume, /actions and endpoint doors declare and emit that member once in their FLOW_FAILED details (the ResumeFailureDetailsSchema pattern); the console renders it by the record-save rule. message keeps the diagnostic text, so logs, SDK, approvals, the stored run and every existing wording assertion are unchanged.
    2. A flow ACTION that fails cannot carry the author's errorMessage or the run summary — the /actions door has no channel for them #9585's two-key ceiling is relaxed by exactly this one declared member; AutomationResult.code stays closed (no new ledger code). A, B, C and E are not taken; D is contained in B′.
    3. The unique sentence naming the object API name is not this card: objectql: a unique refusal's user-facing sentence names the object by its API name ("Duplicate record refused on 'crm_account': …"), which a screen-flow user and a record-save user both read verbatim #24951 (engine, pm:queue) carries it, unchanged by this ruling.
    4. Route F (HotCRM errorMessage on its six write-bearing screen flows) is app work the repo:hotcrm seat may do in parallel; it is not this card's fix and this ruling does not order it.

    Workload of the chosen letter (the card's column): spec, services, cli, ui — four lanes and one ruling (this one); no precedent.

    Execution, in order. (1) #24996 (domain:spec p1 pm:queue): the AutomationResult members and the one details declaration, Clause-②: yes (widening), filed by the director in this act. (2) This card, domain:services, after #24996: the four catch arms of crud-nodes.ts read declaredUserMessage(err) and the inner code, the engine carries them on both terminal exits and the retry exits (serial after PR #24778, same file), and the cli half — the four doors, the MCP bridges, the deps.error userMessage slot — is declared cross-lane on #22648 as the seat planned. (3) objectstack-ai/objectui#13285 (domain:ui p2 pm:blocked): the FlowRunner reads errorMessage, userMessage, the inner refusal, then message; filed by the director in this act. State: needs-user-decision → pm:blocked, Blocked-by: #24996.

  6. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-21T10:04Z. ⛔ Not a claim.

    Blocked-by: #24996: #24996 closed completed at 2026-10-21T04:32Z. This is step (2) of ruling B′'s execution order: the four catch arms of crud-nodes.ts, with the cli half declared cross-lane on #22648. objectstack-ai/objectui#13285 (step 3) follows this card.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:servicespm:queuepriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions