You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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.
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.
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.
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"
]
}
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/maine4d604efd39f 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/main53dea27afab5 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); #94465322867819 (the three doors converge); #9585 (/actions details carry exactly two fields — relaxed by this ruling for one declared member); #225076093472708 (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
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.
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.erroruserMessage 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.
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.
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 14 · 2026-10-20T16:58Z
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers 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, offorigin/main, for running probes; nothing committed)Domain:
domain:servicesSeat:
domain:services#1File surface: none written. Read:
packages/services/service-automation/src/builtin/crud-nodes.ts(thecreate_record/update_record/delete_record/get_recordwrappers, ~:542–855),service-automation/src/engine.ts(Node '<id>' failed:at ~:12168and what the run result keeps of the inner error),packages/runtime/src/domains/automation.tsandflow-dispatch-status.ts(the400 FLOW_FAILEDenvelope and itsdetails, under the maintainer rulings on #9378 and #9446), the spec'sautomation-api.zod.tsanderror-code-ledger.zod.tsfor what the envelope's shape promises, and the console's flow runner in/home/user/objectuifor 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 mapClause-②: 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 conversionThread-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:
service-automation(this lane);FLOW_FAILEDenvelope inpackages/runtime(domain:cli), a flow-dispatch contract the maintainer ruled on automation: bothtriggerroutes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378 and The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446;domain:spec);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/main71696121a6.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
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
objectstack-fleet commentedon Oct 20, 2026
决策请求 ·
domain:servicesseat 1 ·os-justin·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T17:30Z依据:只读测量报告
6296914512(本卡),读数基于 objectstackorigin/main71696121a6、objectui878a4af/8cd4db4。认领6296417944的范围是只读,没有写任何代码。一句话问题: 用户在 screen flow 里点保存,被平台拒绝(重复记录、校验规则、权限)时,弹窗和提示条显示的是一串英文开发者文本,里面带着节点 id 和对象 API 名。同样的拒绝如果发生在普通记录表单里,用户看到的是能看懂、能照着处理的话。
背景
Node 'create_account' failed: create_record(crm_account) failed: Duplicate record refused on 'crm_account': …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);flowResponse.ts:345-355)。userMessage,权限拒绝替换成本地化句子,其余去掉一层前缀(objectuiform.tsx:3023-3026)。userMessage;details由裁决定死(/actions门恰好两个键);AutomationResult.code是闭合联合;Governing text
ApiErrorSchema(packages/spec/src/api/contract.zod.ts:50-97):message是诊断通道,userMessage是生产方标记的终端用户文本,后者「never replaces message」。triggerroutes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378 裁决5321043983(「同意」):「AutomationResult.code stays closed … any genuinely NEW ledger code … goes through the spec seat」。/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446 裁决5322867819(「同意」):状态表是 flow-dispatch 契约的属性,三门收敛。errorMessageor the runsummary— the/actionsdoor has no channel for them #9585(「接受你的所有建议。」,认领5339708379引):/actions门「carries exactly two fields into error.details … No new schema keys」。descriptionhave no key in theflowstranslation face — the zh-CN intake wizard translates its labels and placeholders and keeps its options and body text in English (17.7.0) #22507 裁决6093472708(「22507 同意」):服务端按次渲染、给用户读的流程字符串,要在渲染处按 run 的语言翻译。65846bc46,http-dispatcher.ts:1204-1207):所有路由统一用UNIQUE_VIOLATION。协议声明、是否改协议
推荐的路线要改协议:在
AutomationResult上加一个可选成员,并给三门的FLOW_FAILEDdetails 声明一个新成员。这是加宽,按Clause-②: yes (widening)声明。它放宽了 #9585 的两键上限,所以按定级判据属协议类,由维护者裁决。前提(各带 re-check 命令)
git grep -n "failed: \${(err as Error).message}" packages/services/service-automation/src/builtin/crud-nodes.ts,应命中 4 处。FLOW_FAILEDdetails 不带内层 code。re-check:git grep -n "the #9585 ruling does not cover" packages/runtime/src/action-execution.ts(应命中 1 处,:1050)userMessage。re-check:在 objectui 里跑git grep -n userMessage packages/app-shell/src/utils/flowResponse.ts,应为 0 命中。选项 × 真实代价 × 工作量
message改写成内层句子crm_account、仍是英文,权限拒绝露出[Security] Access denied原文;SDK、MCP、审批侧读到的诊断文本少了节点和操作userMessage通道userMessage,三门作为信封顶层userMessage发出,控制台优先显示{code(wire 拼写), message}作为FLOW_FAILEDdetails 的一个成员,在 spec 声明一次、三门都发;控制台沿用记录表单的规则显示message不变,诊断方不受影响,现有措辞断言一条不动业务含义直译:
四轴(业务立场)
message仍是诊断通道,符合ApiErrorSchema对两个通道的拆分。A 和 B 反过来改写诊断通道。推荐:B′,内层 code 用
UNIQUE_VIOLATION拼写duplicate-record-error.ts:131-138),任何路线都要靠一张引擎子卡另行处理。errorMessage:属于应用补位;按「平台相关的功能,应该尽量先改平台」(维护者 2026-10-18,台账6245607810),席位不推荐它作为本卡的修法。裁后执行(选 B′ 时)
AutomationResult加可选成员userMessage与内层拒绝成员,并在三门的FLOW_FAILEDdetails 声明一次(仿ResumeFailureDetailsSchema)。按Clause-②: yes (widening)做契约复核。crud-nodes.ts的四个 catch 分支读出declaredUserMessage(err)与内层 code(转成 wire 拼写);domain:cli: trigger、resume、/actions、endpoint 四门与 MCP 桥发出新成员,deps.error增加userMessage槽。在 [PM seat] domain:cli · seat 2 — 🟢 os-justin · session_01LmjU2x1faLZ39DVTFyVca6 #22648 声明。errorMessage、userMessage、内层拒绝(套记录表单规则)、message。相关单与 PR
triggerroutes still answer HTTP 200 wrapping an inner {success:false} — the same #3962 residue #8684 closed on resume #9378、The other two flow-dispatch doors never got the #9378 status table —/actionsmislabels never-dispatched refusals, declared endpoints still answer 200 #9446、A flow ACTION that fails cannot carry the author'serrorMessageor the runsummary— the/actionsdoor has no channel for them #9585、A screen flow's select options and screendescriptionhave no key in theflowstranslation face — the zh-CN intake wizard translates its labels and placeholders and keeps its options and body text in English (17.7.0) #22507(裁决)userMessage渲染裁决)os-decision-facets
Prior rulings read:
FLOW_FAILED details·AutomationResult.code closed·userMessage·UNIQUE_VIOLATION wire→ 6 hits (#93785321043983, #93845321043167, #94465322867819, #9585 claim5339708379, #225076093472708, objectui#52105344182262); ADR none named by the code comments; thread: none.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
指针 ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T17:34Z:决策6297041774裁后执行第 5 步的引擎子卡已立为 #24951(唯一约束拒绝句点名对象 API 名,domain:engine由分诊定级)。它不随本卡的裁决变化。Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
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:servicesseat 1) and the read-only measurement 6296914512; the director's chat delta re-checked onorigin/maine4d604efd39fthe four flattening catch arms (crud-nodes.ts, 4 hits of thefailed: ${…message}template),action-execution.ts:1050("a contract widening the #9585 ruling does not cover") andcontract.zod.ts:93(userMessage), and on objectuiorigin/main53dea27afab5thatflowResponse.tsnever readsuserMessage(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:messageis the diagnostic channel,userMessagethe producer-marked end-user text that never replaces it); #9378 ruling5321043983(AutomationResult.codestays closed); #94465322867819(the three doors converge); #9585 (/actionsdetails carry exactly two fields — relaxed by this ruling for one declared member); #225076093472708(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_VIOLATIONon every route).What the ruling binds
messageunchanged.AutomationResultgains an optionaluserMessageand an optional inner-refusal member{ code, message }(code in its wire spelling:UNIQUE_VIOLATIONfor a unique refusal; in-flow$error.codestaysDUPLICATE_RECORD); the trigger, resume,/actionsand endpoint doors declare and emit that member once in theirFLOW_FAILEDdetails (theResumeFailureDetailsSchemapattern); the console renders it by the record-save rule.messagekeeps the diagnostic text, so logs, SDK, approvals, the stored run and every existing wording assertion are unchanged.errorMessageor the runsummary— the/actionsdoor has no channel for them #9585's two-key ceiling is relaxed by exactly this one declared member;AutomationResult.codestays closed (no new ledger code). A, B, C and E are not taken; D is contained in B′.pm:queue) carries it, unchanged by this ruling.errorMessageon its six write-bearing screen flows) is app work therepo:hotcrmseat 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:specp1pm:queue): theAutomationResultmembers 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 ofcrud-nodes.tsreaddeclaredUserMessage(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, thedeps.erroruserMessageslot — is declared cross-lane on #22648 as the seat planned. (3) objectstack-ai/objectui#13285 (domain:uip2pm:blocked): the FlowRunner readserrorMessage,userMessage, the inner refusal, thenmessage; filed by the director in this act. State:needs-user-decision→pm:blocked,Blocked-by: #24996.feat(spec): FLOW_FAILED carries the refused write's inner refusal — A…
objectstack-fleet commentedon Oct 21, 2026
Unlock:
pm:blocked→pm:queueTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-21T10:04Z. ⛔ Not a claim.Blocked-by: #24996: #24996 closedcompletedat 2026-10-21T04:32Z. This is step (2) of ruling B′'s execution order: the four catch arms ofcrud-nodes.ts, with the cli half declared cross-lane on #22648. objectstack-ai/objectui#13285 (step 3) follows this card.Generated by Claude Code