Skip to content

Commit 70943dd

Browse files
fix: save_notice follow-ups: name the array triggerType, pin the persisted approval round trip, classify the node in flow lint (#28805)
domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA Fixes #28786 Clause-②: no Metadata-protocol: none ## 维护者速读 - 这是 #28251(`save_notice`)合并后审查 6562490492 提出的三条 should-fix,只补措辞、测试和 lint 分类,契约、放置判定和线上的 `notice` 都没变。 - 数组形式的 `triggerType`(比如 `['record-after-create', 'record-after-update']`)仍然被拒,但拒绝文案现在会照原样引出这个数组,说明这种写法不支持、永远不会绑定,并让作者只写一个触发类型(`'record-after-write'` 同时覆盖新建和更新)。 - 新增一个集成测试,走真实的 `sys_automation_run` 持久化往返:审批之前的 `save_notice` 让新建应答带回了提示;审批决定后从存储恢复的分支上,第二个 `save_notice` 按名字被拒。实测行为与推断一致,没有发现缺陷。 - lint 的两张节点表都加入了 `save_notice`:写在它上面的 `condition` 会提示无效,放在没有 `try_catch` 的循环体里会提示可能失败。 - 没有需要你拍板的事项。 ## What changes **Spec: the array trigger form is named (`save-notice-placement.ts`).** A `save_notice` node in a flow whose start writes `triggerType` as an array was refused with "This flow's start has no record trigger", which misnames the shape. The verdict is unchanged, and the refusal now says what the author wrote: > This flow's start writes `triggerType` as an array (`['record-after-create', 'record-after-update']`). That form is not supported and never binds, so no write waits on the run and the notice would reach nobody. Give the start node ONE trigger type: `'record-after-write'` fires on both a create and an update. A string trigger and a missing trigger keep their existing sentence. **Coverage: the persisted approval round trip** (`plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts`). It uses a real kernel: `ObjectQLPlugin` over `SqlDriver` (better-sqlite3), `AutomationServicePlugin` with its default durable store (`ObjectStoreSuspendedRunStore` over `sys_automation_run`), and `RecordChangeTriggerPlugin`. The real `ApprovalService` runs behind the real approval node. The flow is `record-after-create` → `save_notice` → `approval` → (approve) `save_notice` → end. The test reads: - The create's own `onNotice` heard "Sent Acme to your manager for approval". - The run is persisted as a `paused` row at the approval node. Its `context_json` holds no `saveNotice.set`: JSON drops the function. - The approve decision resumes the run from the store (the store is the resume authority, #13617). The resumed `save_notice` is refused by name: `save_notice 'tell_after': this run holds no record write to answer`. The run's history row is `failed` with that error, and the listener heard nothing more. - Because the resumed leg failed, `decide` answers the approvals' existing stranded-decision failure (#4420). That failure names the refusal and carries `strandedDecisionDetails` `{ finalized: true, decision: 'approve', runId }`. This is existing behaviour, read here, not a new refusal. **Lint: `save_notice` in the node-type lists** (`lint-flow-patterns.ts`), classified by reading its executor, as the lists' headers ask: - `INERT_CONDITION_NODE_TYPES`: the executor parses `message` only and never reads `condition`. - `FALLIBLE_NODE_TYPES`: it returns `success: false` on data alone. That happens when the run holds no write (a resumed leg, a subflow or map child), when the channel is already closed, or when every hole of the message rendered empty (`save-notice-node.ts:67, :94, :104`). ## Why These are the three should-fixes of review 6562490492 on #28753. ## Verification - **Red first.** - Item 1's sentence pin (`spec/src/automation/save-notice-node.test.ts`) was 1 failed of 18 before the fix and 18/18 after. - Item 3's two lint readings (`lint/src/lint-flow-patterns.test.ts`: inert `condition` on a `save_notice`, and `save_notice` in the fallible-builtin list) were 2 failed before and 245/245 after. - **Ablation of the coverage pin (item 2).** I replaced the executor's no-channel `refuseNode(...)` with `return { success: true }`. The round-trip test went red: `decide` no longer threw, so "expected 'undefined' to contain 'save_notice 'tell_after': this run …'". I restored it and checked it was byte-identical by blob (`ea8be53d`), and the test was green again. The test resolves `@objectstack/service-automation` through its built `dist/`, so I ablated the built file the test runs. - **Gates.** `dispatch-gates.mjs --commands` derived 84; `--ran` reconciles 84 run, 0 NOT-MEASURED, 0 UNRUN. Five gates first exited 3 (prerequisite not met): four spec gates read a spec `dist` older than a later test edit, and `check:dual-build-cjs-loads` needs every package's `dist`. I rebuilt them and reran all five: exit 0. - **Suites.** Full `vitest run` of `@objectstack/spec`, `@objectstack/lint` and `@objectstack/plugin-approvals`: exit 0 each. - `eslint --no-inline-config` over `packages/spec`, `packages/lint` and `packages/plugins/plugin-approvals` exits 0. ## Acceptance notes - With a `save_notice` placed after an approval in a record-triggered flow, the parse still accepts the flow, because it cannot see where a run suspends. The approver's decision is recorded, and the resumed run fails at the node. The decision answers the stranded-decision failure, which names the node. This PR pins that reading and does not change it. - No `ui/**` and no hotcrm changes. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: no generated artifact moves (the change is refusal text only). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5omF1nCbQTGgW1nbRA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70bf434 commit 70943dd

6 files changed

Lines changed: 265 additions & 5 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
fix: the `save_notice` placement refusal names an array `triggerType`, and the flow lint classifies `save_notice` (#28786)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Follow-ups to #28251. The verdicts do not change: what was refused is still refused, and what was accepted is still accepted.
12+
13+
- **Spec: the array trigger form is named.** A flow whose start writes `triggerType` as an array (`['record-after-create', 'record-after-update']`) was refused at parse with "this flow's start has no record trigger", which misnames the shape. The refusal now quotes the array, says that form is not supported and never binds, and tells the author to give the start node one trigger type (`'record-after-write'` fires on both a create and an update).
14+
- **Lint: `save_notice` joins the node-type lists.** The executor never reads `condition`, so a `condition` written on a `save_notice` node now draws the inert-condition finding, as it does for the other builtins that ignore it. The node can also fail at run time on data alone (a leg resumed after a suspension, a subflow or map child, or a message whose every hole rendered empty), so it is now counted with the other fallible builtins: one in a loop body with no `try_catch` around it draws the uncontained-loop-body warning (`flow-loop-body-uncontained`).

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,15 @@ describe('flow-inert-node-condition (#4414)', () => {
13141314
expect(fnds[0].hint).toContain("incoming edge's `condition`");
13151315
});
13161316

1317+
it('flags it on a save_notice node — its executor never reads the key (#28786)', () => {
1318+
const fnds = lintFlowPatterns(
1319+
conditionNodeFlow('save_notice', { message: 'Sent for approval', condition: 'record.amount > 1000' }),
1320+
).filter((f) => f.rule === FLOW_INERT_NODE_CONDITION);
1321+
expect(fnds).toHaveLength(1);
1322+
expect(fnds[0].where).toContain("node 'n' (save_notice)");
1323+
expect(fnds[0].hint).toContain("incoming edge's `condition`");
1324+
});
1325+
13171326
it('does NOT flag the start node — that is where the key is read', () => {
13181327
expect(lintFlowPatterns({
13191328
flows: [{
@@ -2134,6 +2143,9 @@ describe('per-iteration containment (#13681 / #14394)', () => {
21342143
{ id: 'n9', type: 'subflow', label: 'Subflow', config: { flowName: 'child' } },
21352144
{ id: 'n10', type: 'map', label: 'Map', config: { collection: { dialect: 'cel', source: 'items' } } },
21362145
{ id: 'n11', type: 'approval', label: 'Approval', config: { approvers: ['{$User.Id}'] } },
2146+
// [#28786] A message whose holes all render empty, or a run that holds
2147+
// no write to answer (a resumed leg, a subflow/map child), fails it.
2148+
{ id: 'n12', type: 'save_notice', label: 'Tell', config: { message: '{{ currentCase.name }}' } },
21372149
];
21382150
const fnds = lintFlowPatterns(sweep(fallible)).filter((f) => f.rule === FLOW_LOOP_BODY_UNCONTAINED);
21392151
expect(fnds.map((f) => f.where)).toEqual(

‎packages/lint/src/lint-flow-patterns.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ const INERT_CONDITION_NODE_TYPES = new Set([
321321
'create_record', 'update_record', 'delete_record', 'get_record',
322322
'http', 'notify', 'script', 'screen', 'wait', 'subflow', 'map',
323323
'connector_action', 'approval', 'end',
324+
// [#28786] read: `save-notice-node.ts` parses `message` only
325+
// (`SaveNoticeConfigSchema`) and never looks at `condition`.
326+
'save_notice',
324327
]);
325328

326329
/** Node types that perform a data operation — the ones `flow.runAs` governs (#1888). */
@@ -359,6 +362,11 @@ const DATA_NODE_TYPES = new Set(['get_record', 'create_record', 'update_record',
359362
* context, or a throwing request (`plugin-approvals/src/approval-node.ts:136,
360363
* :145-147, :212`). Plugin-registered but in-box, and this file already
361364
* imports its type constant for the ADR-0044 rules.
365+
* - `save_notice` (#28786) — the run holds no write to answer (a leg resumed
366+
* after a suspension, a subflow or map child), the trigger already closed the
367+
* channel, or every `{{ }}` hole of the message rendered empty
368+
* (`save-notice-node.ts:67, :94, :104`). Which of those a row hits is data,
369+
* not authoring.
362370
*
363371
* Deliberately **absent**, each also by reading the executor:
364372
*
@@ -382,7 +390,7 @@ const DATA_NODE_TYPES = new Set(['get_record', 'create_record', 'update_record',
382390
const FALLIBLE_NODE_TYPES: ReadonlySet<string> = new Set([
383391
'get_record', 'create_record', 'update_record', 'delete_record',
384392
'http', 'notify', 'connector_action', 'script', 'subflow', 'map',
385-
APPROVAL_NODE_TYPE,
393+
APPROVAL_NODE_TYPE, 'save_notice',
386394
]);
387395

388396
/**
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28786] A `save_notice` node ahead of an `approval` node, across a real
5+
* persisted suspension.
6+
*
7+
* ## What is claimed (#28251)
8+
*
9+
* The record-change trigger hands a record-triggered run the triggering
10+
* write's notice channel (`AutomationContext.saveNotice`). A `save_notice`
11+
* node BEFORE the approval node sets the sentence on that write's answer. The
12+
* approval node then suspends the run, the run state is persisted as JSON in
13+
* `sys_automation_run`, and the decision resumes it from there. JSON keeps the
14+
* channel's `locale` and drops its `set` function, so a `save_notice` node on
15+
* the resumed leg finds no channel and is refused by name: there is no write
16+
* left to answer.
17+
*
18+
* The executor's own suite pins that refusal with a hand-built context. This
19+
* suite pins it through the real round trip, so a store that started keeping
20+
* (or re-binding) the channel, or an engine that resumed from an in-memory copy
21+
* instead of the store, is caught here.
22+
*
23+
* ## The rig
24+
*
25+
* A real kernel: `ObjectQLPlugin` over `SqlDriver` (better-sqlite3, in memory,
26+
* real DDL), `AutomationServicePlugin` with its default (`'auto'`) durable store
27+
* (`ObjectStoreSuspendedRunStore` over `sys_automation_run`, which the plugin
28+
* registers), and `RecordChangeTriggerPlugin`. The real `ApprovalService` is
29+
* attached to the real automation engine behind the real approval node, so the
30+
* decision resumes the run the way a deployment does. A resumed leg that fails
31+
* makes the decision answer the approvals' stranded-decision failure (#4420),
32+
* which names the run's own refusal; that is existing behaviour, read here,
33+
* not a new refusal.
34+
*
35+
* ## Coverage pin
36+
*
37+
* Ablated: with the executor's no-channel refusal replaced by a success, the
38+
* resumed-leg assertions go red (`save-notice-node.ts`), and are green again
39+
* once it is restored.
40+
*/
41+
42+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
43+
import { ObjectKernel } from '@objectstack/core';
44+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
45+
import { SqlDriver } from '@objectstack/driver-sql';
46+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
47+
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
48+
import type { EngineQueryOptions } from '@objectstack/spec/data';
49+
import { strandedDecisionDetails } from '@objectstack/types';
50+
import { ApprovalService } from './approval-service.js';
51+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
52+
import { registerApprovalNode } from './approval-node.js';
53+
import { installDefaultOrganization } from './default-organization.testkit.js';
54+
55+
const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any;
56+
const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any;
57+
const silent = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any;
58+
59+
const SUBMITTER = 'u_sub';
60+
const APPROVER = 'u_mgr';
61+
const FLOW = 'deal_sent_for_approval';
62+
63+
const text = (name: string, label: string) => ({ name, label, type: 'text' as const });
64+
const sysUser = {
65+
name: 'sys_user',
66+
label: 'User',
67+
fields: { id: { ...text('id', 'Id'), primaryKey: true }, name: text('name', 'Name'), email: text('email', 'Email') },
68+
};
69+
const deal = {
70+
name: 'snr_deal',
71+
label: 'Deal',
72+
fields: { name: text('name', 'Name') },
73+
};
74+
75+
/**
76+
* The shape #28251 documents: tell the saver it went for approval, then ask.
77+
* The approve leg carries a second `save_notice`, which runs only after the
78+
* decision resumed the run from storage.
79+
*/
80+
const flow = {
81+
name: FLOW,
82+
label: 'Deal sent for approval',
83+
type: 'record_change',
84+
nodes: [
85+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: deal.name, triggerType: 'record-after-create' } },
86+
{ id: 'tell', type: 'save_notice', label: 'Tell', config: { message: 'Sent {{ record.name }} to your manager for approval' } },
87+
{ id: 'ask', type: 'approval', label: 'Manager', config: { approvers: [{ type: 'user', value: APPROVER }] } },
88+
{ id: 'tell_after', type: 'save_notice', label: 'Tell again', config: { message: '{{ record.name }} was approved' } },
89+
{ id: 'ok', type: 'end', label: 'Approved' },
90+
{ id: 'no', type: 'end', label: 'Rejected' },
91+
],
92+
edges: [
93+
{ id: 'e1', source: 'start', target: 'tell' },
94+
{ id: 'e2', source: 'tell', target: 'ask' },
95+
{ id: 'e3', source: 'ask', target: 'tell_after', label: 'approve' },
96+
{ id: 'e4', source: 'ask', target: 'no', label: 'reject' },
97+
{ id: 'e5', source: 'tell_after', target: 'ok' },
98+
],
99+
};
100+
101+
/** The plugin's own object definitions, as its manifest registers them. */
102+
async function packageObjects(): Promise<any[]> {
103+
let manifest: any;
104+
const ctx: any = {
105+
getService: (name: string) => {
106+
if (name === 'manifest') return { register: (m: any) => { manifest = m; } };
107+
throw new Error(`no service '${name}'`);
108+
},
109+
logger: silent,
110+
};
111+
await new ApprovalsServicePlugin().init(ctx);
112+
return manifest.objects;
113+
}
114+
115+
describe('[#28786] a save_notice before an approval answers the write, and one on the leg resumed from storage refuses by name', () => {
116+
let kernel: ObjectKernel;
117+
let ql: ObjectQL;
118+
let svc: ApprovalService;
119+
120+
/** Every `sys_automation_run` row of the flow, live pause or terminal history. */
121+
const runRows = async (): Promise<any[]> =>
122+
ql.find('sys_automation_run', { where: { flow_name: FLOW }, context: SYSTEM } satisfies EngineQueryOptions);
123+
124+
beforeAll(async () => {
125+
kernel = new ObjectKernel({ logger: { level: 'silent' } } as any);
126+
await kernel.use(new ObjectQLPlugin());
127+
await kernel.use(new AutomationServicePlugin());
128+
await kernel.use(new RecordChangeTriggerPlugin());
129+
await kernel.bootstrap();
130+
ql = kernel.getService<ObjectQL>('objectql');
131+
const automation = kernel.getService<AutomationEngine>('automation');
132+
133+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
134+
await driver.connect();
135+
ql.registerDriver(driver, true);
136+
for (const def of [sysUser, deal, ...(await packageObjects())]) {
137+
ql.registry.registerObject(def as any, 'save-notice-round-trip', 'save-notice-round-trip');
138+
}
139+
await ql.syncSchemas();
140+
await installDefaultOrganization(ql as never);
141+
for (const id of [SUBMITTER, APPROVER]) {
142+
await ql.insert('sys_user', { id, name: id, email: `${id}@example.test` }, { context: SYSTEM });
143+
}
144+
145+
svc = new ApprovalService({ engine: ql as any, logger: silent });
146+
svc.attachAutomation(automation as any);
147+
registerApprovalNode(automation as any, svc, silent);
148+
automation.registerFlow(FLOW, flow as any);
149+
});
150+
151+
afterAll(async () => {
152+
try { await kernel?.shutdown(); } catch { /* noop */ }
153+
});
154+
155+
it('the create hears the sentence, the run is persisted without the channel, and the resumed save_notice refuses', async () => {
156+
// ── The write: the save_notice ahead of the approval answers it.
157+
const heard: unknown[] = [];
158+
const created: any = await ql.insert(
159+
deal.name,
160+
{ name: 'Acme' },
161+
{ context: { userId: SUBMITTER }, onNotice: (message: unknown) => { heard.push(message); } } as any,
162+
);
163+
expect(heard, "the create's own answer carried the notice").toEqual(['Sent Acme to your manager for approval']);
164+
165+
// ── Suspended and persisted: one live row, its context stored as JSON.
166+
const [pause] = await runRows();
167+
expect(pause, 'the run suspended at the approval node and was persisted').toBeDefined();
168+
expect(pause.node_id).toBe('ask');
169+
expect(pause.status, 'a live pause, not a terminal history row').toBe('paused');
170+
const stored = JSON.parse(String(pause.context_json));
171+
// What survives JSON: the channel object without its function.
172+
expect(stored.saveNotice === undefined || typeof stored.saveNotice === 'object').toBe(true);
173+
expect(typeof stored.saveNotice?.set, 'no function survives the persisted context').toBe('undefined');
174+
175+
// ── The decision resumes the run from the store.
176+
const [request] = await ql.find('sys_approval_request', {
177+
where: { record_id: created.id, status: 'pending' }, context: SYSTEM,
178+
} satisfies EngineQueryOptions);
179+
expect(request?.flow_node_id, 'the approval node opened its request').toBe('ask');
180+
// The resumed leg fails at its save_notice, so the decision answers the
181+
// approvals' existing stranded-decision failure (#4420): the decision is
182+
// recorded and the run's own refusal is named in it.
183+
let thrown: unknown;
184+
try {
185+
await svc.decide(request.id, { decision: 'approve', actorId: APPROVER }, asUser(APPROVER));
186+
} catch (err) {
187+
thrown = err;
188+
}
189+
expect(String((thrown as Error | undefined)?.message)).toContain(
190+
"save_notice 'tell_after': this run holds no record write to answer",
191+
);
192+
expect(strandedDecisionDetails(thrown)).toMatchObject({ finalized: true, decision: 'approve', runId: pause.id });
193+
194+
// ── The resumed leg's save_notice is refused by name, and nothing reaches the write.
195+
const rows = await runRows();
196+
expect(rows.some((r) => r.id === pause.id), 'the live pause was consumed').toBe(false);
197+
const terminal = rows.find((r) => String(r.id) === `run_${pause.id}`);
198+
expect(terminal, 'the resumed run left its history row').toBeDefined();
199+
expect(terminal.status).toBe('failed');
200+
expect(String(terminal.error)).toContain("save_notice 'tell_after': this run holds no record write to answer");
201+
expect(heard, 'the resumed leg set nothing on the answer already sent').toEqual(['Sent Acme to your manager for approval']);
202+
}, 20000);
203+
});

‎packages/spec/src/automation/save-notice-node.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ describe('FlowSchema — a save_notice node stands only in a flow a record creat
8989
expect(issues[0].message).toContain('no write waits on the run');
9090
});
9191

92+
it('names the ARRAY triggerType form and asks for one trigger type (#28786)', () => {
93+
const flow = flowWith(undefined);
94+
(flow.nodes[0] as any).config = { objectName: 'deal', triggerType: ['record-after-create', 'record-after-update'] };
95+
const issues = placementIssues(FlowSchema.safeParse(flow));
96+
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['nodes', 1, 'type']]]);
97+
expect(issues[0].message).toContain(
98+
"This flow's start writes `triggerType` as an array (`['record-after-create', 'record-after-update']`). "
99+
+ 'That form is not supported and never binds, so no write waits on the run and the notice would reach '
100+
+ "nobody. Give the start node ONE trigger type: `'record-after-write'` fires on both a create and an "
101+
+ 'update.',
102+
);
103+
expect(issues[0].message).not.toContain('no record trigger');
104+
});
105+
92106
it('is refused inside a region body too, at that node', () => {
93107
const flow = {
94108
name: 'deal_loop', label: 'Deal loop', type: 'autolaunched',

‎packages/spec/src/automation/save-notice-placement.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,18 @@ export function saveNoticePlacementRefusals(
6060
if (!hasNotice) return out;
6161
const trigger = startTriggerType(flow as { nodes?: unknown });
6262
if (typeof trigger === 'string' && SAVE_NOTICE_TRIGGER.test(trigger.trim())) return out;
63-
const written = typeof trigger === 'string' ? `\`triggerType: '${trigger}'\`` : 'no record trigger';
63+
// [#28786] The ARRAY form (`['record-after-create', 'record-after-update']`)
64+
// is refused with the same verdict, but named as what it is: multi-event
65+
// unions are unsupported and never bind (#3457), so "no record trigger"
66+
// would misname the shape and leave the author guessing what to change.
67+
const situation = Array.isArray(trigger)
68+
? `This flow's start writes \`triggerType\` as an array (\`[${trigger.map((t) => `'${String(t)}'`).join(', ')}]\`). `
69+
+ 'That form is not supported and never binds, so no write waits on the run and the notice would reach '
70+
+ "nobody. Give the start node ONE trigger type: `'record-after-write'` fires on both a create and an "
71+
+ 'update.'
72+
: `This flow's start has ${typeof trigger === 'string' ? `\`triggerType: '${trigger}'\`` : 'no record trigger'}, `
73+
+ 'so no write waits on the run and the notice would reach nobody. Start the flow from the record write, '
74+
+ 'or tell the person another way (a `notify` node).';
6475
for (const graph of graphs) {
6576
graph.nodes.forEach((node, index) => {
6677
if ((node as { type?: unknown } | null)?.type !== 'save_notice') return;
@@ -70,9 +81,7 @@ export function saveNoticePlacementRefusals(
7081
'A `save_notice` node tells the person who made the write that fired this run what the save set in '
7182
+ 'motion, so it runs only in a flow started by that write: a start node whose `config.triggerType` is '
7283
+ "a create or update record trigger (`'record-after-update'`, `'record-before-create'`, "
73-
+ `\`'record-after-write'\`, …). This flow's start has ${written}, so no write waits on the run and `
74-
+ 'the notice would reach nobody. Start the flow from the record write, or tell the person another way '
75-
+ '(a `notify` node).',
84+
+ `\`'record-after-write'\`, …). ${situation}`,
7685
});
7786
});
7887
}

0 commit comments

Comments
 (0)