Skip to content

Commit 2cba2eb

Browse files
feat(spec)!: refuse at publish a save_notice that a suspending node reaches (#28838)
domain:server#3 · os-project-manager · session_01FEkQ5omF1nCbQTGgW1nbRA Fixes #28806 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: follow-up #28787 — `skills/**` does not teach `save_notice` yet; that open card carries the teaching, which should state this placement rule beside the trigger rule ## 维护者速读 - **BREAKING(收窄)**:记录触发的流程里,如果 `save_notice` 节点能沿连线从 `approval`、`wait`、`screen` 或 `approval_revise` 之后到达,现在发布时就会被拒绝,返回 `INVALID_METADATA` 422,位置在 `nodes.<i>.type`。以前它能通过发布,但每次审批决定都会让流程卡住(`RESUME_FAILED`)。 - 拒绝文案会点名那个挂起节点,并告诉作者:把 `save_notice` 挪到它前面,或者在它后面改用 `notify` 节点。 - 按席位裁定,只要有一条路径经过挂起节点能到达,就拒绝,包括回环;放在挂起之前的 `save_notice` 照常通过。 - 挂起节点集合直接复用已有的 `FLOW_PAUSE_CAPABLE_NODE_TYPES`。`subflow`、`map` 只在子流程挂起时才挂起,发布时看不到,所以不计入,仍由运行时按名字拒绝。 - 已发布的流程没有这种形状:showcase 唯一的 `save_notice` 前面没有挂起节点,HotCRM main 里没有 `save_notice`。 - `Metadata-protocol` 实测为 `narrowed`(派单预计 `none`):按 AGENTS.md,作者能写的值被拒就是 narrowed,因此 spec 是 `minor`。详见下文和报告的 deviations。 - 没有需要你拍板的事项。合并前由席位做契约复核。 ## What changes **Spec: the placement door refuses a `save_notice` that a suspending node reaches** (`packages/spec/src/automation/save-notice-placement.ts`). After the trigger check passes, the door walks the flow's top-level `edges`. It takes every node that a node of an always-suspending type reaches from its successors, where that suspending node is itself reachable from the start. A `save_notice` in that set is refused: > A `save_notice` node tells the person who made the write that fired this run what the save set in motion, so it runs only while the run is still inside that write. This one is reached after `approval` node 'ask' suspends the run. A suspended run resumes after the write has already answered, so on that path the node is refused by name at run time, and after an approval the decision cannot finish its run. Move the `save_notice` ahead of 'ask', or tell the person after it with a `notify` node. - **Which nodes suspend.** The door reuses `FLOW_PAUSE_CAPABLE_NODE_TYPES` (`screen`, `wait`, `approval`, `approval_revise`), the list of types that pause on every execution. `flow.zod.ts` passes it in, so the door's module imports nothing back from `flow.zod.ts`. - `subflow` and `map` declare `supportsPause: true` but pause only when the child flow they name pauses. That is a different record, which the parse does not hold, so they are not counted. That is the same classification ruling D made for region bodies. A `save_notice` after one still meets the run-time refusal by name. - **Reachability.** Any path counts, per the seat ruling, including mixed graphs and loops. An edge after the approval that loops back to before the notice makes the notice reachable from the approval. - **Region bodies.** A region body cannot hold a pausing node (the region rule refuses one). So a `save_notice` inside a `loop` / `parallel` / `try_catch` body is judged by its top-level container, and the refusal is anchored at the nested node's own path. - **Shape.** `ctx.addIssue({ code: 'custom' })` at `nodes.<i>.type`, which the metadata door answers as `INVALID_METADATA` 422, the same as the trigger case. `objectstack validate`, the save door and `registerFlow` refuse it alike. **Test re-anchored: `plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts`** (#28786). Its flow placed the second `save_notice` right after the approval, which is the shape this PR refuses, so `registerFlow` now throws in its setup. The test keeps its purpose: - The approval is now asked in a `subflow` (`start → save_notice → subflow(approval) → save_notice`). The parse accepts that graph. The parent suspends with its child, is persisted in `sys_automation_run`, and resumes from the store when the decision completes the child. - The resumed `save_notice` still refuses by name. Through a subflow the decision answers the approvals' existing subflow-parent strand: `resumed: true`, with `resumeFailure: { code: 'RESUME_FAILED', runId: <parent>, status: 'stranded' }` and `resumeError` naming the refusal. It does not throw. That is existing behaviour, read and not changed. - A second case pins this PR's refusal at `registerFlow` on the original #28786 graph. ## Why #28806: an AI writing "notify after approval" with `save_notice` produced a graph that published and then stranded every decision. The author now learns at publish time. ## Verification - **Red first.** All 8 new refusal pins in `spec/src/automation/save-notice-node.test.ts` were red before the change (8 failed, 21 passed of 29) and green after (29/29). The 3 controls passed on both sides. - **Door pins** (`metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts`, #28806 block). In publish and draft mode they assert `{ code: 'INVALID_METADATA', status: 422 }`, `[['custom', 'nodes.2.type']]`, the sentence verbatim and nothing stored. The control (notice ahead of the approval) is stored. - **Ablations, each red and then restored** (spec source through `scripts/ablation-replace.mjs`, restore proven by `blob == HEAD`; `dist` by hand, restore proven by blob hash): - Never refuse: the 8 spec pins went red, then the 2 door pins and the `registerFlow` pin (spec `dist`). - Direct successors only: the several-hops and loop-back pins went red. - Ignore the region container: the region pin went red. - Count everything reachable from the start: the two "still parses" controls went red. - Add `subflow` to the pausing list: the subflow control went red. - #28786's run-time pin, re-anchored on the subflow: with the executor's no-channel refusal disabled in `service-automation/dist`, the round-trip test went red (blob `505babcc` restored). - **Shipped flows.** `examples/**` and `packages/dogfood`: one `save_notice` (`examples/app-showcase` `showcase_task_completed`: `start → script → save_notice → notify → end`), with no suspending node. HotCRM `origin/main` (`a598256`): no `save_notice`. - **Gates.** `dispatch-gates.mjs --commands` derived 84; `--ran` reconciles 84 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. - **Suites.** Full `vitest run` of `@objectstack/spec`, `@objectstack/metadata-protocol`, `@objectstack/plugin-approvals`, `@objectstack/service-automation`, `@objectstack/trigger-record-change` and `@objectstack/example-showcase`: exit 0 each. The round-trip file's two cases run in about 0.3 s of test time. - **Dogfood battery** (owed by the `(narrowing)` arm), on head `bb9723f5` under `os-verify-lock.sh`: closure build `VERDICT command-exit 0`; `pnpm --filter @objectstack/dogfood test` `VERDICT command-exit 0`, Test Files 380 passed and 3 skipped (383), Tests 3114 passed and 28 skipped (3142), no failure. - `eslint --no-inline-config` over `packages/spec`, `packages/metadata-protocol` and `packages/plugins/plugin-approvals` exits 0. ## Acceptance notes - `Metadata-protocol: narrowed`, not the `none` the order expected. AGENTS.md defines "a value refused" as `narrowed`, and a flow graph an author could save before is now refused at the save door. The seat-1 contract review on #26912 ruled the same way for a save door refusing what it stored before. Under ruling A the level follows the kind, so `@objectstack/spec` is `minor`. No other package's shipped files move. - The `RESUME_FAILED` / stranded-decision posture, the save-notice contract and the wire `notice` are unchanged. No `ui/**` and no hotcrm changes. - A plugin-registered pausing type is not in the list (the namespace is open), so a `save_notice` after one still meets the run-time refusal. ## 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 (`check-generated` and the spec artifact gates pass on this head). - [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 a96bf3c commit 2cba2eb

6 files changed

Lines changed: 413 additions & 43 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a `save_notice` node that a suspending node reaches is refused at the flow parse (#28806)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
**BREAKING** for a flow that places a `save_notice` node after an `approval`, `wait`, `screen` or `approval_revise` node on a path from its start.
11+
12+
- **Before.** The placement check (#28251) read only the start trigger, so a record-triggered flow with `start → approval → save_notice` parsed and saved. At run time the leg after the suspension holds no write to answer, so the node was refused by name. After an approval the decision was already recorded when the resumed run failed, so every decision on such a flow stranded its run (`RESUME_FAILED`).
13+
- **Now.** The `FlowSchema` parse also refuses a `save_notice` node reachable along the flow's edges from a node that always suspends the run (`FLOW_PAUSE_CAPABLE_NODE_TYPES`: `screen`, `wait`, `approval`, `approval_revise`). Any one such path counts, loops included, even when another path reaches the node before any suspension, because a static graph cannot rule the suspended path out. A `save_notice` inside a region body is judged by its top-level container. The refusal is the same `INVALID_METADATA` 422 at `nodes.<i>.type` as the trigger case. It names the suspending node and tells the author to move the `save_notice` ahead of it, or to tell the person after it with a `notify` node. `objectstack validate`, the metadata save door and `registerFlow` refuse it alike.
14+
- **Not refused.** A `save_notice` reachable only before any suspension. A `subflow` or `map` ahead of the node: those pause only when the child flow they name pauses, which the parse cannot see, so a `save_notice` after one still meets the run-time refusal by name.
15+
- **The fix.** Move the `save_notice` ahead of the suspending node the refusal names. To tell someone after the approval, use a `notify` node there.
16+
- **Who moves.** No shipped flow has the refused shape: the showcase's one `save_notice` (`showcase_task_completed`) has no suspending node, and HotCRM's `main` carries no `save_notice`.
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed, renamed or re-shaped and no export or type changes: one more graph shape is refused at the parse with its own prescription in the error (move the node ahead of the suspending node, or use a notify node after it), which is a judgment about where the node stands, not a mechanical rewrite a migration could apply -->

‎packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,3 +732,57 @@ describe('[#22088] a flow edge that names no node, or repeats an earlier edge, i
732732
expect([...rows.values()].map((r) => [r.type, r.name])).toEqual([['flow', 'repro_edges']]);
733733
});
734734
});
735+
736+
describe('[#28806] a save_notice reachable from a suspending node is refused at the metadata door', () => {
737+
const NOTICE_FLOW = (order: 'after' | 'before') => ({
738+
name: 'deal_submit',
739+
label: 'Deal submit',
740+
type: 'record_change',
741+
nodes: [
742+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'deal', triggerType: 'record-after-create' } },
743+
{ id: 'ask', type: 'approval', label: 'Manager', config: { approvers: [{ type: 'user', value: 'u_mgr' }] } },
744+
{ id: 'tell', type: 'save_notice', label: 'Tell the saver', config: { message: 'Sent {{ record.name }} for approval' } },
745+
{ id: 'end', type: 'end', label: 'End' },
746+
],
747+
edges: order === 'after'
748+
? [
749+
{ id: 'e1', source: 'start', target: 'ask' },
750+
{ id: 'e2', source: 'ask', target: 'tell', label: 'approve' },
751+
{ id: 'e3', source: 'ask', target: 'end', label: 'reject' },
752+
{ id: 'e4', source: 'tell', target: 'end' },
753+
]
754+
: [
755+
{ id: 'e1', source: 'start', target: 'tell' },
756+
{ id: 'e2', source: 'tell', target: 'ask' },
757+
{ id: 'e3', source: 'ask', target: 'end', label: 'approve' },
758+
{ id: 'e4', source: 'ask', target: 'end', label: 'reject' },
759+
],
760+
});
761+
762+
it.each([
763+
['publish', undefined],
764+
['draft', 'draft'],
765+
] as const)('%s mode — 422 INVALID_METADATA at the node\'s type, naming the approval, nothing stored', async (_label, mode) => {
766+
const { protocol, rows } = makeProtocol();
767+
const err = await saveFlowAsAdministrator(protocol, NOTICE_FLOW('after'), mode);
768+
769+
expect(err).toBeInstanceOf(Error);
770+
expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
771+
const issues = err.issues as Array<{ code?: string; path?: string; message: string }>;
772+
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', 'nodes.2.type']]);
773+
expect(issues[0]!.message).toContain(
774+
"This one is reached after `approval` node 'ask' suspends the run. A suspended run resumes after the write "
775+
+ 'has already answered, so on that path the node is refused by name at run time, and after an approval the '
776+
+ "decision cannot finish its run. Move the `save_notice` ahead of 'ask', or tell the person after it with a "
777+
+ '`notify` node.',
778+
);
779+
expect(rows.size).toBe(0);
780+
});
781+
782+
it('CONTROL — the same flow with the notice ahead of the approval is stored', async () => {
783+
const { protocol, rows } = makeProtocol();
784+
const result = await saveFlowAsAdministrator(protocol, NOTICE_FLOW('before'));
785+
expect(result instanceof Error ? `${result.message} ${JSON.stringify((result as any).issues ?? [])}` : 'stored').toBe('stored');
786+
expect([...rows.values()].map((r) => [r.type, r.name])).toEqual([['flow', 'deal_submit']]);
787+
});
788+
});

‎packages/plugins/plugin-approvals/src/save-notice-approval-round-trip.integration.test.ts‎

Lines changed: 100 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@
88
*
99
* The record-change trigger hands a record-triggered run the triggering
1010
* 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
11+
* node BEFORE the approval sets the sentence on that write's answer. The
12+
* approval then suspends the run, the run state is persisted as JSON in
1313
* `sys_automation_run`, and the decision resumes it from there. JSON keeps the
1414
* channel's `locale` and drops its `set` function, so a `save_notice` node on
1515
* the resumed leg finds no channel and is refused by name: there is no write
1616
* left to answer.
1717
*
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.
18+
* [#28806] A `save_notice` that a suspending node reaches on the flow's own
19+
* graph is now refused at the parse, so the resumed leg is reached through a
20+
* `subflow` that asks the approval: the parse cannot see that a subflow
21+
* pauses (it does only when its child does), so that is where the run-time
22+
* refusal is still the guard. The parse refusal itself is pinned at
23+
* `registerFlow` in the second case.
24+
*
25+
* The executor's own suite pins the run-time refusal with a hand-built
26+
* context. This suite pins it through the real round trip, so a store that
27+
* started keeping (or re-binding) the channel, or an engine that resumed from
28+
* an in-memory copy instead of the store, is caught here.
2229
*
2330
* ## The rig
2431
*
@@ -27,10 +34,10 @@
2734
* (`ObjectStoreSuspendedRunStore` over `sys_automation_run`, which the plugin
2835
* registers), and `RecordChangeTriggerPlugin`. The real `ApprovalService` is
2936
* 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.
37+
* decision resumes the run the way a deployment does. A resumed subflow parent
38+
* that fails makes the decision answer the approvals' existing subflow-parent
39+
* strand (#4420: `resumeFailure` names the parent run, `resumeError` its
40+
* refusal); that is existing behaviour, read here, not a new refusal.
3441
*
3542
* ## Coverage pin
3643
*
@@ -46,7 +53,6 @@ import { SqlDriver } from '@objectstack/driver-sql';
4653
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
4754
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
4855
import type { EngineQueryOptions } from '@objectstack/spec/data';
49-
import { strandedDecisionDetails } from '@objectstack/types';
5056
import { ApprovalService } from './approval-service.js';
5157
import { ApprovalsServicePlugin } from './approvals-plugin.js';
5258
import { registerApprovalNode } from './approval-node.js';
@@ -74,18 +80,64 @@ const deal = {
7480

7581
/**
7682
* 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.
83+
*
84+
* [#28806] A `save_notice` that a suspending node reaches on the flow's own
85+
* graph is refused at the parse now, so the second notice cannot stand right
86+
* after the `approval` node any more (`DIRECT_AFTER` below is that refusal's
87+
* pin at `registerFlow`). The approval is asked in a SUBFLOW instead: a
88+
* `subflow` pauses only when its child does, which the parse cannot see, so
89+
* the graph is accepted, the parent suspends with its child, persists, and
90+
* resumes from storage when the decision completes the child. The resumed
91+
* parent leg is where the second `save_notice` meets the run-time refusal.
7992
*/
93+
const CHILD = 'deal_manager_review';
94+
const child = {
95+
name: CHILD,
96+
label: 'Manager review',
97+
type: 'autolaunched',
98+
nodes: [
99+
{ id: 'start', type: 'start', label: 'Start' },
100+
{ id: 'ask', type: 'approval', label: 'Manager', config: { approvers: [{ type: 'user', value: APPROVER }] } },
101+
{ id: 'ok', type: 'end', label: 'Approved' },
102+
{ id: 'no', type: 'end', label: 'Rejected' },
103+
],
104+
edges: [
105+
{ id: 'c1', source: 'start', target: 'ask' },
106+
{ id: 'c2', source: 'ask', target: 'ok', label: 'approve' },
107+
{ id: 'c3', source: 'ask', target: 'no', label: 'reject' },
108+
],
109+
};
110+
const START = { id: 'start', type: 'start', label: 'Start', config: { objectName: deal.name, triggerType: 'record-after-create' } };
111+
const TELL = { id: 'tell', type: 'save_notice', label: 'Tell', config: { message: 'Sent {{ record.name }} to your manager for approval' } };
112+
const TELL_AFTER = { id: 'tell_after', type: 'save_notice', label: 'Tell again', config: { message: '{{ record.name }} was approved' } };
80113
const flow = {
81114
name: FLOW,
82115
label: 'Deal sent for approval',
83116
type: 'record_change',
84117
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' } },
118+
START,
119+
TELL,
120+
{ id: 'review', type: 'subflow', label: 'Manager review', config: { flowName: CHILD } },
121+
TELL_AFTER,
122+
{ id: 'ok', type: 'end', label: 'Done' },
123+
],
124+
edges: [
125+
{ id: 'e1', source: 'start', target: 'tell' },
126+
{ id: 'e2', source: 'tell', target: 'review' },
127+
{ id: 'e3', source: 'review', target: 'tell_after' },
128+
{ id: 'e4', source: 'tell_after', target: 'ok' },
129+
],
130+
};
131+
/** The #28786 shape: the second notice right after the approval, on this flow's own graph. */
132+
const DIRECT_AFTER = {
133+
name: 'deal_notice_after_approval',
134+
label: 'Deal notice after approval',
135+
type: 'record_change',
136+
nodes: [
137+
START,
138+
TELL,
87139
{ 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' } },
140+
TELL_AFTER,
89141
{ id: 'ok', type: 'end', label: 'Approved' },
90142
{ id: 'no', type: 'end', label: 'Rejected' },
91143
],
@@ -145,6 +197,7 @@ describe('[#28786] a save_notice before an approval answers the write, and one o
145197
svc = new ApprovalService({ engine: ql as any, logger: silent });
146198
svc.attachAutomation(automation as any);
147199
registerApprovalNode(automation as any, svc, silent);
200+
automation.registerFlow(CHILD, child as any);
148201
automation.registerFlow(FLOW, flow as any);
149202
});
150203

@@ -164,40 +217,51 @@ describe('[#28786] a save_notice before an approval answers the write, and one o
164217

165218
// ── Suspended and persisted: one live row, its context stored as JSON.
166219
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');
220+
expect(pause, 'the run suspended with its child and was persisted').toBeDefined();
221+
expect(pause.node_id).toBe('review');
169222
expect(pause.status, 'a live pause, not a terminal history row').toBe('paused');
170223
const stored = JSON.parse(String(pause.context_json));
171224
// What survives JSON: the channel object without its function.
172225
expect(stored.saveNotice === undefined || typeof stored.saveNotice === 'object').toBe(true);
173226
expect(typeof stored.saveNotice?.set, 'no function survives the persisted context').toBe('undefined');
174227

175-
// ── The decision resumes the run from the store.
228+
// ── The decision completes the child, which resumes the parent from the store.
176229
const [request] = await ql.find('sys_approval_request', {
177230
where: { record_id: created.id, status: 'pending' }, context: SYSTEM,
178231
} 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.
232+
expect(request?.flow_node_id, "the child's approval node opened its request").toBe('ask');
233+
// The parent's resumed leg fails at its save_notice. The child's own run
234+
// resumed and completed, so the decision answers with the parent named as
235+
// the stranded run (the approvals' existing subflow-parent strand, #4420):
236+
// recorded, not thrown, and the parent's own refusal named in it.
237+
const answered: any = await svc.decide(request.id, { decision: 'approve', actorId: APPROVER }, asUser(APPROVER));
238+
expect([answered.finalized, answered.decision, answered.resumed]).toEqual([true, 'approve', true]);
239+
expect(answered.resumeFailure).toMatchObject({ code: 'RESUME_FAILED', runId: pause.id, status: 'stranded' });
240+
expect(String(answered.resumeError)).toContain("save_notice 'tell_after': this run holds no record write to answer");
241+
242+
// ── The resumed leg's save_notice is refused by name, and nothing reaches the write.
243+
let terminal: any;
244+
for (let i = 0; i < 100 && !terminal; i++) {
245+
terminal = (await runRows()).find((r) => String(r.id) === `run_${pause.id}`);
246+
if (!terminal) await new Promise((r) => setTimeout(r, 20));
247+
}
248+
expect(terminal, 'the resumed parent left its history row').toBeDefined();
249+
expect((await runRows()).some((r) => r.id === pause.id), 'the live pause was consumed').toBe(false);
250+
expect(terminal.status).toBe('failed');
251+
expect(String(terminal.error)).toContain("save_notice 'tell_after': this run holds no record write to answer");
252+
expect(heard, 'the resumed leg set nothing on the answer already sent').toEqual(['Sent Acme to your manager for approval']);
253+
}, 20000);
254+
255+
it('[#28806] the same notice right after the approval on the flow\'s own graph is refused at registerFlow, naming the approval', () => {
256+
const automation = kernel.getService<AutomationEngine>('automation');
183257
let thrown: unknown;
184258
try {
185-
await svc.decide(request.id, { decision: 'approve', actorId: APPROVER }, asUser(APPROVER));
259+
automation.registerFlow(DIRECT_AFTER.name, DIRECT_AFTER as any);
186260
} catch (err) {
187261
thrown = err;
188262
}
189263
expect(String((thrown as Error | undefined)?.message)).toContain(
190-
"save_notice 'tell_after': this run holds no record write to answer",
264+
"This one is reached after `approval` node 'ask' suspends the run.",
191265
);
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);
266+
});
203267
});

‎packages/spec/src/automation/flow.zod.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,8 +1639,9 @@ export const FlowSchema = lazySchema(() => strictObject(
16391639
// [#28251] A `save_notice` node answers the write that fired the run, so it
16401640
// stands only in a flow a create or update record trigger starts — anywhere
16411641
// else no write waits on the run. Refused at each such node, at any region
1642-
// depth; the reasons are `save-notice-placement.ts`'s header.
1643-
for (const refusal of saveNoticePlacementRefusals(flow)) {
1642+
// depth; the reasons are `save-notice-placement.ts`'s header. [#28806] Also
1643+
// refused where a node that always suspends the run reaches it.
1644+
for (const refusal of saveNoticePlacementRefusals(flow, FLOW_PAUSE_CAPABLE_NODE_TYPES)) {
16441645
ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message });
16451646
}
16461647

0 commit comments

Comments
 (0)