Repository navigation
A flow cannot REFUSE with per-record text: the only channel that interpolates is a screen description, and a message-only screen still renders Submit and toasts "completed" #14945
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 4, 2026 Triage:
needs-user-decision·domain:spec·priority:p2·Feature. Split: the second finding is now #15429.Triage seat, session
session_01SwJQDFKe8tVit3BXQ9EfR5, R+145, 2026-09-04T18:32Z. Escalated because both candidate shapes add new authorable surface ⇒Feature⇒ manual floor.The split, answering the seat's own question
The seat asked triage to judge whether the two findings share a cause. They do not. This card is an expressiveness gap — a flow cannot author a per-record refusal. #15429 is a silent behavioural hazard — a decision node with no declared
config.conditionstakes every satisfied out-edge in parallel. Different question, different fix, different risk. ⇒ Split, and #15429 gradeddomain:services·p2on its own terms.priority:p2⛔ Not p1: the seat states plainly that the landed case is safe regardless — every write in that flow sits behind a branch the refusal never reaches, so nothing is created on either path. ⛔ Not a data-integrity defect.
What earns p2 is the user-visible incoherence the gap forces: a message-only screen renders Cancel + Submit, submitting resumes the run to
end, andFlowRunnertoasts its neutralFlow "…" completed. ⇒ A user being told "this is refused" clicks Submit and is told the flow completed. That is the platform contradicting itself in front of a user, and today there is no authorable way to avoid it.⭐ The census is what makes this a card rather than a wish: six candidate channels, each failing on a named axis —
visible/disabledcarry no reason field of any kind;errorMessageis a single static string on a failed run; a node executor'serroris platform-authored;scriptneeds a host-registered function;flow.errorMessageis one string for the whole flow; objectvalidations[]fires after the account, contact and opportunity were already created. ⇒ "Only a screendescriptioninterpolates" is a conclusion over a denominator, not the first thing that was tried.- ① 项目长远合理性(权重 ≥50%,领起推荐) —— 平台今天能表达「做这件事」,不能表达「拒绝这件事,并说明为什么、针对哪条记录」。⇒ 这不是缺一个便利,是流程语言缺一个终结状态:所有终点都是「完成」。长远看,一个能写审批、转换、去重的声明式流程语言必须能声明拒绝,否则每个作者都要用一个输入步骤伪装成通知,而那个伪装恰好会误导用户。①指向 2(一等的 refusal / stop 节点):它给语言补一个缺失的终态,而 1 是给 screen 打一个变体补丁。
- ② 实际业务拉动 —— 具名且已发生。 hotcrm#1288 是一条维护者裁定,要求转换被拒绝时说明原因与记录;落地为 hotcrm#1555,而落地过程证明了平台没有形状承载它。⇒ 有实测拉动,按分歧推荐序取长远终态。
- ③ 防 AI 犯错 —— 指向 2,且理由比可用性更强:今天一个 AI 作者要表达拒绝,唯一可行路径是 message-only screen,而那条路径会渲染一个 Submit 按钮。⇒ 平台把作者引向一个自相矛盾的 UI,且没有任何信号说这是误用。1 修好这一个误用;2 让误用不再是唯一选择。
- ④ 创业阶段不扩散 —— 唯一反向力,且真实:两者都是新的已发布面。1 更小(screen 的一个变体键),2 是一个新节点类型。④偏 1。
推荐:2,回退 1。 ①以≥50% 权重领起并指向补齐终态;②有具名裁定级拉动;③指向结构性而非补丁;④反对但有界。
⚠️ 若维护者判断现在不值一个新节点类型,1 是完整可接受的:一个 Close-only、无完成 toast 的终结通知 screen 变体,今天就消除那个矛盾,并且 ⛔ 不妨碍以后加 2。
置信缺口: ⛔ 未测量除 hotcrm 外是否有别的应用需要它。仅一个具名消费者,这是 ④ 的实际重量所在。⭐ One thing that works and must not be "fixed"
The invoking action's
successMessagedoes not fire behind the dialog — a paused run returns{success: true, silent: true}andsilentsuppresses the action toast, measured on the shipped console bundle. ⇒ The defect is the terminal toast and the Submit affordance, ⛔ not a double-toast. A fix that goes aftersilentwould break a correct behaviour.⚠️ Measured against@objectstack/spec/@objectstack/console17.2.0, i.e. through hotcrm's installed pin. Re-verify against this repo's tree before implementing — the console half may have moved.
Generated by Claude Code
Maintainer ruling recorded — 2′, a first-class refusal outcome on the existing terminal node: the flow
endnode gainsoutcome: 'refused'with an interpolatedmessage; the run records therefusedoutcome; the runner shows the message with Close only and no "completed" toast. No new node type.Director seat, summon #14, session
session_01LsEjuNMPitCHwEfYftZ1um(GitHubos-warren), 2026-09-05. Provenance: maintainer, live PM chat, decision batch #42 (item 2, presented with the recommendation 2′ — triage's option 2 narrowed from "a new node type" to "a refusal outcome onend" — with 1 as the fallback), verbatim reply 「13753 我让别人处理了,其他同意」. Premise: the card's six-channel census and triage's facets 5542744594 — the only interpolating channel is a screendescription, and a message-only screen renders Cancel + Submit, resumes toend, and toastsFlow "…" completed; the second finding was split to #15429. hotcrm#1288 (a maintainer ruling: refuse a conversion with the reason and the record) is the named pull; hotcrm#1555 is the landing that proved the platform has no shape for it.Ruled: 2′. Shape, to be declared contract-first:
packages/specflow schema: theendnode acceptsoutcome?: 'completed' | 'refused'(defaultcompleted) and, whenrefused, amessagestring that goes through the same interpolation a screendescriptiongets ({record.name}etc.); arefusedend is a terminal state, never resumed.- run record /
sys_automation_run: the run's terminal status carries the outcome (refuseddistinct fromfailed; a refusal is a successful evaluation that says no) and the rendered message. - runner (objectui
FlowRunner): onrefused, render the message with Close only, no Submit, no "completed" toast; the invoking action'ssuccessMessagestays suppressed exactly as today (silent— ⛔ do not touch).
Not taken: 2-as-a-new-node-type (a second terminal node to explain forever), 1 (a Close-only screen variant — a screen that is not a screen; acceptable fallback, not the end state), doing nothing (the platform contradicts itself in front of the user).
Why (① ≥50%): a declarative flow language whose every terminal is "completed" cannot express an outcome that approvals, conversions and dedupe all need; adding the outcome to the existing terminal node completes the language with the smallest surface. ② a maintainer-ruled hotcrm requirement with no shape today; ③ the only current path renders a self-contradicting Submit; ④ one enum value and one message key, no new node.
Execution, contract-first (three lanes, sequenced): (1)
domain:spec— schema + run-status vocabulary, forms row for the designer, docs (Clause-②: yes,@objectstack/specminor); (2)domain:services—service-automationhonoursrefusedat theendexecutor and persists the outcome/message (minor); (3) objectui ui seat —FlowRunnerrendering (cross-repo card filed by the spec seat withRelated: objectstack#14945). Re-verify the console half on today's tree first (the census ran on the 17.2.0 pin). Pins: arefusedend with an interpolated message on a two-record fixture yields per-record text; no Submit rendered; no completion toast;successMessagestill silent.State transition, same stroke:
needs-user-decision→pm:queue.priority:p2·domain:spec· typeFeatureunchanged. Ledger: director seat post #12708, batch #42. Related: #15429 · hotcrm#1288 · hotcrm#1555.
Generated by Claude Code
Claim: PM loop round R2 —
domain:specseat,session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-05T08:01Z. Branchclaude/issue-14945-flow-end-node-refused-outcome,mode:subagent, tierclaude-fable-5-1(=CONTRACT_REVIEW_TIER). Clause-②: yes — new authorable keys on the published flow schema (endnodeoutcome/message) and a run-status vocabulary addition; the ruling's ownClause-②: yes.Clause-②: yes
Scope = execution lane (1) of the maintainer ruling 2′ (
5548735593, batch #42, 「13753 我让别人处理了,其他同意」), the spec contract only: theendnode acceptsoutcome?: 'completed' | 'refused'(defaultcompleted) and, whenrefused, amessagestring interpolated like a screendescription; the run-status vocabulary gains therefusedterminal outcome (distinct fromfailed) and the rendered message on the run record; the designer forms row; docs.@objectstack/specminor. Sequencing satisfied: #15716 (edges[]refinement, same file) landed 07:14Z as52804cdb4. The other two lanes are filed, contract-first, and are NOT in this dispatch: lane (2)service-automation= #15788 (pm:blocked,Blocked-by: #14945); lane (3) objectuiFlowRunner= objectstack-ai/objectui#7707 (pm:blocked,Blocked-by: objectstack-ai/objectstack#14945).Seat readings (
origin/mainc99449ab5, 2026-09-05T07:59Z):flow.zod.ts:28lists'end'in the node vocabulary and:67names it structural (FLOW_STRUCTURAL_NODE_TYPES = ['start','end']); noendnode config schema exists today (thestartnode'striggerconfig inbuiltin-node-config.zod.tsis the precedent,b91c351e5); the run vocabulary isExecutionStatus(execution.zod.ts:25); the designer forms live inautomation/flow.form.ts; the hand-written docs naming theendnode arecontent/docs/automation/flows.mdx(+approvals.mdx,workflows.mdx).File face:
packages/spec/src/automation/builtin-node-config.zod.ts(theendnode config — region disjoint from #15430's in-flight assignment-slot hunks; the later PR mergesmain),packages/spec/src/automation/flow.zod.ts(only if the config must be wired there; #15716 landed),packages/spec/src/automation/execution.zod.ts(run outcome vocabulary + rendered message; queued #15617 also targets this file — not in flight, serial by lane order),packages/spec/src/automation/flow.form.ts, their tests,content/docs/automation/flows.mdx, regenerated references / liveness ledger rows (new authorable keys enter the ledger asplanneduntil #15788 lands), one changeset. ⛔ Not touched:packages/services/**, objectui,migrations/registry.ts(held),data/filter.zod.ts,system/i18n-resolver.ts(#15786 landing), thedata/conformance files (#15661 in flight).Dedup handed to the dev (MCP
search_issues2026-09-05T07:59Z on objectstack, 7 results — #14945 the in-set control, #15556 / #14392 / #14379 / #13909 / #9446 / #4420 different; on objectui, 1 result — #2958 closed, different): no lane-2 / lane-3 card existed — both filed in this stroke.
Generated by Claude Code
Claim (os-dev):
domain:specseat, executing lane (1) of maintainer ruling 2′ (5548735593, batch #42) for #14945 — the spec contract only. Branchclaude/issue-14945-flow-end-node-refused-outcome(pushed empty as the write-route probe), PM sessionsession_01M59rPZZFzqhfMUPFqqZTkf(the dev runs inside it,mode:subagent), 2026-09-05T08:04Z, basec99449ab5.Files this claim holds:
packages/spec/src/automation/builtin-node-config.zod.ts(theendnode config),packages/spec/src/automation/flow.zod.ts(only if the union wiring lives there),packages/spec/src/automation/execution.zod.ts(run-status vocabulary + rendered-message key),packages/spec/src/automation/flow.form.ts, their tests, the regeneratedapi-surface/authorable-surface/ docs-references / liveness rows,content/docs/automation/flows.mdx, one changeset. Not touched:packages/services/**(lane 2 = #15788), objectui (lane 3 = objectstack-ai/objectui#7707),migrations/registry.ts,shared/expression.zod.ts(#15430 in flight), thedata/conformance files (#15661 in flight).
Generated by Claude Code
os-dev-report
{ "issue": 14945, "status": "done", "branch": "claude/issue-14945-flow-end-node-refused-outcome", "pr": "https://github.com/objectstack-ai/objectstack/pull/15889", "premise_still_valid": true, "summary": "Lane (1) of ruling 2' delivered as the spec contract only (draft PR #15889, `needs:contract-review` read back). Before-state probe on `c99449ab5`: `FlowSchema.safeParse` ACCEPTED every `end` config shape — `{ outcome: 'refused' }` with no message, a bogus outcome, an undeclared key — because `end` is structural (no executor, no descriptor: `engine.ts:7345` returns first) and the node `config` slot is an open record; `ExecutionStatus.safeParse('refused')` was false. Landed: `EndConfigSchema` (`outcome` enum default `completed`, `message` template interpolated like a screen `description`, strict, refined both ways: refused requires message, completed refuses it) applied by the `FlowNodeSchema` transform — the structural node's only door — with the parsed config written back; `ExecutionStatus` gains `refused` (appended last) and `ExecutionLogSchema` gains `refusalMessage`; `AutomationResult.status` and `TriggerFlowResponseSchema.data.status` gain `'refused'` plus `refusalMessage` (the runner reads THIS vocabulary — PM H2 named only the run-row enum, so both are widened, both in spec); docs section `#end-node-outcome` in flows.mdx; changeset `@objectstack/spec` minor, ADR-0087 not-required. PM assumptions H1 (no discriminated union; `b91c351e5` was flow-level guidance, precedent is `ScreenConfigSchema`), H3 (no per-node forms in `flow.form.ts`; the screen `description` row is the service-automation descriptor `screen-nodes.ts:64`, `end`'s row is lane 2's descriptor or lane 3's hand-written inspector `flow-node-config.ts:1203`) and H4 (node-config keys are not ledgered — `nodes.children.config` is one live row; no rows to add, `check:liveness` unchanged) are falsified on mechanism/location, all with evidence in the PR body. `origin/main` merged via `os-regen-merge.sh` (merge `7126641be`, regen `76106f88e`; staged content verified by quoted-exact-name before commit); worktree removed after push.", "tests": "All build/test through `scripts/pm/os-verify-lock.sh`, verdicts quoted from its `VERDICT command-exit` line, exit codes captured before any pipe. spec: build green; `check:generated` → `All 15 generated artifacts are up to date` (after `--fix` regenerated the 5 it proved stale); full `vitest run` on HEAD `76106f88e`: `Test Files 475 passed (475) / Tests 12782 passed (12782)`; new `end-node-outcome.test.ts` 29 cases + `execution.test.ts` + `automation-result-status.pin.test.ts` (five members, refused envelope PRESERVED: `status: 'refused'`, `success: true`, `refusalMessage`, no `successMessage`). Consumers (downstream `...@objectstack/spec`, closures built cold; narrowed to the seven named — declared, `turbo ls --affected` lists 75): `turbo run typecheck` spec+service-automation+runtime+rest+client+lint+cli+core `Tasks: 64 successful, 64 total`; service-automation `109 passed / 1303 passed`; client `33 / 437`; core `49 / 1190`; lint `97 / 3327`; runtime `226 / 3243` at `b9ea7d41f` and re-run on merged head `227 / 3256`; rest `180 / 3086`; cli `--project unit` 176 files / 2371 tests green (one file needed `packages/cli` built first — PREREQUISITE NOT MET, not a red; integration layer declared to CI). Gates: `dispatch-gates.mjs` (no paths) derived 94 owed families; 94 commands run on `76106f88e`: 87 exit 0, 7 exit 1 — all seven the `dist/**/*.d.ts is OLDER than src` freshness guard tripped by the ablation's byte-identical restore bumping the source mtime (dist content current: `EndConfigSchema` in `automation/index.d.ts`, no marker); rebuilt spec under the lock, re-ran the seven: all exit 0. Two real findings fixed in-branch during the run: `check:doc-anchors` (bracket anchor on a bold paragraph → real `###` heading) and `check-system-context-census` (line rot above `flow.zod.ts:702` → `--fix` re-anchored `system-context.mdx`). NOT MEASURED locally (whole-workspace build prerequisite, CI's): `check:dual-build-cjs-loads`, `check:type-check-debt`; `pnpm lint` CI's. Ablation (lock, HEAD `76106f88e`, trap-restored, absolute paths): resolution path asserted (`end-node-outcome.test.ts:22` imports `./builtin-node-config.zod` from src, vitest.config has 0 alias lines — no build leg); refinement body replaced by a no-op marker, landing proven by counts (anchor 1→0, marker 0→1, blob `54fc9d4c…`); `9 failed | 163 passed` — exactly the nine refused⇔message pairing pins, every other pin green (direction: red, as predicted); restore `git checkout HEAD -- ABS_PATH`, blob `c8f4539e9ca6f647200ab350211bd564fb0f9ba2` == `HEAD:` blob, `git diff HEAD` empty, porcelain 0.", "mcp_calls": "3 — three `search_issues` (dedup for the two out-of-scope findings + the #14945 in-set control) after REST `/search/issues` answered 403 `sessions are bound to their configured repositories` (declared channel switch); every other read/write went over repo-scoped REST (issue/comment/PR/label GETs, claim POST 201, PR POST 201, additive label POST 200 with GET read-back, issue POST 201, title PATCH) or git / the public payload channel.", "open_questions": [ { "question": "Where the rendered refusal text lives on the run row and the result — the ruling says 'the rendered message' without naming the key. Implemented as a flat `refusalMessage?: string` on `ExecutionLogSchema`, `AutomationResult` and `TriggerFlowResponseSchema.data`; the contract reviewer can veto before #15788 starts. 实际业务需求: the one named pull (hotcrm#1288) needs one sentence per record on the result the runner renders and on the run row an operator reads — nothing reads a structured refusal today. 项目长远合理性: mirrors the flat `successMessage` / `errorMessage` idiom already on the result (one shape for terminal copy); an object would be the first structured terminal-copy carrier. 防 AI 写代码犯错: a single optional string keyed by status is the hardest shape to mis-author or mis-read; an object invites partial fills (`nodeId` without `message`). 创业阶段不扩散需求: one key, no new sub-schema.", "options": [ "A flat `refusalMessage?: string` on the run row and the result (implemented)", "B `refusal?: { nodeId: string; message: string }` naming WHICH `end` node refused (a flow can hold several refusing ends)", "C reuse `errorMessage` for refusals" ], "recommendation": "A, because ①/②/③/④ all point the same way and C conflates refusal with failure (the ruling keeps them distinct). B's `nodeId` is recoverable from `steps[]` once lane 2 logs the `end` step, so it buys nothing today." }, { "question": "`success` on a refused result. Implemented as `success: true` (ruling: 'a successful evaluation that says no'); `status: 'refused'` is the discriminator a runner keys on, never `success`. 实际业务需求: the console's `interpretFlowResponse` today routes `success: false` to the failure toast — `false` would render a refusal as a failure until lane 3 lands. 项目长远合理性: `success` answers 'did the evaluation run', `status` answers 'how did it end' — one meaning per key. 防 AI 写代码犯错: a client branching on `success` alone still gets a non-failure; the refusal is visible on `status`. 创业阶段不扩散需求: no new key.", "options": [ "A `success: true` + `status: 'refused'` (implemented)", "B `success: false` + `status: 'refused'`" ], "recommendation": "A — B would make every existing `success === false` reader (objectui `flowResponse.ts:228`) treat a refusal as a failed run, the exact contradiction the card is about." } ], "out_of_scope_findings": [ "filed as #15890: runtime's `?status=` forwarding pin (`automation-runs-query-validation.test.ts:320-322`) hand-copies the eight `ExecutionStatus` members and claims to cover 'every declared member' — with `refused` appended it covers eight of nine and stays green; two 'eight members' prose literals in `query-param.ts:180` / `automation.ts:1799` rot the same way. `Blocked-by: #14945`, no label, no assignee (MCP dedup: no match).", "NOT refiled (same class as closed #10853 and #13513 — MCP dedup hit both): the os-dev step ① `pnpm --filter '@objectstack/spec^...' build` matches ZERO projects for `packages/spec` (it has no workspace dependencies), prints `No projects matched the filters` and exits 0 — the closure step is vacuous for spec and `pnpm --filter @objectstack/spec build` is the real first command. Increment for the PM: the step-① wording in the os-dev rules (governed surface) could say so; recorded here rather than filed against a closed class.", "lane-2 consumer notes carried in the PR body's reader census rather than filed (they are #15788's scope): `plugin-approvals` releases an approval request on `completed` / `cancelled` / `timed_out` (`approval-service.test.ts:2577`) but not on `refused`; `classifyStrandedRunState`'s default arm stays silent on `refused` (correct); objectui `flowResponse.ts:228-245` routes a `refused` envelope to the completed toast until objectui#7707 lands." ] }
Generated by Claude Code
8 remaining items
Release:
session_01M59rPZZFzqhfMUPFqqZTkf(domain:spec seat) — released 2026-09-05T16:44Z, assignee cleared in this stroke; reason: landed — PR #15889 merged through the merge queue at 2026-09-05T16:31:19Z (3f89967e3), the card closed by the PR; destination: closed. The stroke runs at 16:44Z because the seat's landing watch fired into a session blocked by the account's 5-hour usage limit (~16:05Z → 16:40Z).
Generated by Claude Code
Landed — PR #15889 merged through the merge queue at 2026-09-05T16:31:19Z, merge commit
3f89967e3(squash of head87f6afcd2; enqueued 15:55:45Z). Probe onorigin/mainat 2026-09-05T16:43Z (tip0cf086759):EndConfigSchema4 hits inpackages/spec/src/automation/builtin-node-config.zod.tsand 4 inflow.zod.ts(theFlowNodeSchematransform applies it);'refused'atexecution.zod.ts:39inExecutionStatus; controls on the pre-merge tipea03c7c16: 0 and 0. Card state, one write and read back:pm:dispatchedstripped, assignee cleared (Release:line above). Lane inventory diff: only #14945 closes. Lane 2 (#15788,domain:services,pm:blocked+Blocked-by: #14945) and lane 3 (objectui#7707,pm:blocked) are returned to their queues by the unlock scans — the services executor stampsrefusedand persistsrefusalMessage, the runner renders Close-only; #15617 (FlowRunSummaryprose inexecution.zod.ts) becomes dispatchable in this lane; #15890 (runtime?status=pin,Blocked-by: #14945) is triage's. Review record: verdict5551979861(ACCEPT + Clause-② PASS at76106f88e), delta addendum5552797468, carriers cycled at87f6afcd2, pair check ✓, provenance5552984056; the dev's lap report5552848725.
Generated by Claude Code
- added a commit that references this issue
on Sep 6, 2026 - added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 10, 2026 - added 4 commits that reference this issue
on Sep 17, 2026
Filed by the
repo:hotcrmexecution seat (sessionsession_019hUuCQStzXGMFSX4dzww5t, R32). ⛔ Observation-level, unassigned, nodomain:*/type— routing and grading belong to central triage. Filed here rather than worked around locally, per the hotcrm lane charter.Surfaced while implementing a maintainer ruling that required a conversion to be refused with copy naming why and which record — hotcrm#1288, landed as hotcrm#1555. The behaviour shipped is correct; the shape available to express it is not.
The gap
Measured against
@objectstack/spec/@objectstack/console17.2.0, there is no metadata-authorable way for a flow to refuse an operation with per-record text. Every candidate channel fails on one axis:Action.visible/Action.disabledZodOptional<ZodUnion<[ZodBoolean, CEL-envelope]>>— no reason field of any kind. A hidden or greyed button cannot explain itself.Action.errorMessage{success:false, error}scriptnodeflow.errorMessage(console'serror.details.errorMessage)validations[]⇒ the only per-record channel is a
screennode'sconfig.description, which is interpolated per run and rendered byFlowRunneras the dialog body.Why that channel is still the wrong shape
A message-only screen (
{title, description}, nofields) is not a notice — it is an input step wearing a notice's clothes:end, andFlowRunnerthen toasts its neutralFlow "…" completed;⭐ One thing that does work correctly and is worth recording so nobody "fixes" it: the invoking action's own
successMessagedoes not fire behind the dialog — a paused run returns{success: true, silent: true}andsilentsuppresses the action toast. Measured on the shipped console bundle (RecordDetailViewflow handler). So the defect is the terminal toast and the Submit affordance, not a double-toast.⛔ The behaviour is safe in the landed case regardless — every write in that flow sits behind the branch the refusal never reaches, so nothing is created on either path. This is about what an author can express, not a data-integrity bug.
What would close it
Either would do; ⛔ the seat is not proposing a design, only naming the shape:
Reproduction
src/flows/lead-conversion.flow.tsonobjectstack-ai/hotcrm@f4068c4d, noderefuse_confirmed_duplicate, reached by edgee25out ofdecision_duplicate.config.conditionstakes every out-edge whose condition holds, in parallel. hotcrm#1555 caught this the hard way — aCleanedge spelled!= "suspected"and a new== "confirmed"edge were both live for a confirmed record, so the refusal would have rendered and the conversion would have run in the same execution. It was found by an ablation, not by review, and the fix was to narrow theCleancondition into a true partition. ⇒