Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/notify-zero-delivery-is-distinguishable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/service-automation': patch
---

`notify` now reports the recipients it addressed, so a run that notified nobody stops reading like a run that had nobody to notify

A `notify` node whose delivery count came back zero contributed `acted: 0` and nothing else to the run summary. A flow whose only effect-bearing node is that one then folded to `selected: 0, acted: 0, unmeasured: 0` — byte for byte the summary of a run that had nothing to notify about, and of a run whose `notify` node never executed. The run read healthy, and the only trace was a log line.

`emit()` returns `delivered: 0, enqueued: 0` on several paths, each after logging and nothing else: an audience that resolved to no recipient, a preference filter that suppressed every (recipient × channel) pair, a dedup hit, every enqueue failing. A stack with no messaging service installed lands in the same place. All of them were silent in the summary, so this is not one cause being fixed — it is the whole class becoming visible.

The node now reports `selected` — the recipient entries it addressed — on every path that reaches a recipient list, alongside the `acted` / `unmeasuredEffect` rules it already had. Those two are unchanged, so a delivering run keeps its existing `acted` (inline) or `unmeasured` (outbox) reading and stays outside the broken-sweep filter; a zero-delivery run now reports `selected: N, acted: 0` with no `unmeasured`, which is the platform's declared "matched N, acted on none, and that zero is trustworthy" signature and puts the run **inside** `selected > 0 AND acted = 0 AND unmeasured = 0` — the filter that exists for exactly this, and whose first clause the old reading could never satisfy.

The zero is deliberately NOT reported as `unmeasuredEffect`. That flag means the count is unknown; this count is known and it is zero, and claiming otherwise would take the run out of the very filter it belongs in.

`selected` counts audience entries, not resolved users: the entry (`role:manager`, a bare id) is what the node has, since expansion happens inside the messaging service and is not reported back.
12 changes: 12 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,7 @@ instead:
| `connector_action` | `unmeasured` |
| `script`, function declared pure (the default) | nothing — a registered function is **contractually pure**: data I/O stays on the flow graph, so every write it causes is a downstream node that counts itself |
| `script`, function declared `effect: 'writes'` | `unmeasured` — the function said it writes where the platform cannot see, so the run says the count is incomplete |
| `notify` | `selected`: the recipient entries the node addressed, always. Then `acted: <delivered>` when the messaging stack delivered inline and knows the outcome, or `unmeasured` when it handed the deliveries to the outbox and the dispatcher decides later. A notify that reached **nobody** — an audience that resolved to no recipient, a preference filter that suppressed every pair, a dedup hit, no messaging service installed — reports `selected: N, acted: 0` and no `unmeasured`, which is what puts it inside the broken-sweep filter instead of leaving it silent |

The `script` row is a contract, not a measurement: nothing stops a registered
function from writing, so an **undeclared** writer still makes its run report
Expand All @@ -1071,6 +1072,17 @@ every flow that calls any function, to cover the few that break the rule.
`unmeasured` propagates through `subflow` and `map` roll-ups, so a parent whose
child dispatched an uncountable effect knows its own `acted` is incomplete.

<Callout type="warn">
A `notify` node reports `selected` for the recipients it addressed **whether or
not any of them were reached**, and that is deliberate: it is the only thing
that separates a run which notified nobody from a run that had nobody to
notify. Both used to fold to `selected: 0, acted: 0, unmeasured: 0` — the same
triple a run with no `notify` node at all reports — so a flow that quietly
stopped delivering read exactly like a healthy quiet day, and the broken-sweep
filter could not match it because its first clause is `selected > 0`. The zero
is reported as a **measured** zero, never as `unmeasured`: the count is known.
</Callout>

The same counts land on `sys_automation_run` as **queryable columns**
(`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count`, plus a
`summary_json` breakdown), so a broken sweep is something you can query for
Expand Down
51 changes: 48 additions & 3 deletions packages/services/service-automation/src/builtin/notify-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,13 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
// #4354 — nothing was delivered, and the run summary must say
// so: a nudge sweep whose messaging service is absent is
// precisely the "green but inert" case this counter exists for.
metrics: { acted: 0 },
//
// `selected` is what makes that `acted: 0` READABLE — see the
// block above `metrics` on the emit path below. Without it a
// run whose notify reached nobody folds to
// `selected: 0, acted: 0, unmeasured: 0`, which is the same
// triple a run with no notify node at all reports.
metrics: { selected: recipients.length, acted: 0 },
};
}

Expand Down Expand Up @@ -439,9 +445,48 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
//
// Waiting for the real outcome is not on the table: a notify
// node must not block a flow on a downstream channel.
//
// ── `selected`: what makes a ZERO dispatch readable (#17123) ──
//
// `acted` and `unmeasuredEffect` above answer "what did this
// node cause". Neither can answer "this node tried to notify
// somebody and reached NOBODY", and that answer is the one an
// operator needs: `emit()` has several paths that return
// `delivered: 0, enqueued: 0` after logging a line and nothing
// else — an audience that resolved to no recipient, a
// preference filter that suppressed every (recipient x
// channel) pair, a dedup hit, every enqueue failing. Each of
// them lands here as `{ acted: 0 }`, and a run whose only
// effect-bearing node is this one then folds to
// `selected: 0, acted: 0, unmeasured: 0` — byte for byte the
// summary of a run that had nothing to notify about, and of a
// run whose notify node never executed at all.
//
// ⛔ The fix is NOT to report the zero as `unmeasuredEffect`.
// That flag means "the count is unknown", and this count is
// known and it is zero; claiming otherwise would take the run
// OUT of the broken-sweep filter
// (`selected > 0 AND acted = 0 AND unmeasured = 0`) — the
// platform's own alarm for a green-but-inert sweep — on
// precisely the run that should be inside it.
//
// So the node declares the other half of the pair instead, in
// the key that already means it: `selected` is "records this
// node READ or matched", and the recipients it addressed are
// exactly that. Reporting it costs a delivering run nothing
// (`acted`/`unmeasuredEffect` keep it out of the filter) and
// buys the zero-delivery run its place inside it, which is
// what makes the two runs read differently at all.
//
// It counts audience ENTRIES the node addressed, not resolved
// users: the entry (`role:manager`, a bare id) is what this
// node has: expansion happens inside the messaging service and
// is not reported back. `selected` and `acted` are not
// required to be commensurate anywhere else either — a
// `get_record` selects ten and an update acts on three.
metrics: enqueued > 0
? { ...(delivered > 0 ? { acted: delivered } : {}), unmeasuredEffect: true }
: { acted: delivered },
? { selected: recipients.length, ...(delivered > 0 ? { acted: delivered } : {}), unmeasuredEffect: true }
: { selected: recipients.length, acted: delivered },
};
} catch (err) {
return { success: false, error: `notify failed: ${(err as Error).message}` };
Expand Down
Loading
Loading