From 4d5d782bd8e4e39b1e5ad3789b963b493a630f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 02:09:59 +0000 Subject: [PATCH] docs(spec): the broken-sweep predicate in FlowFunctionEffectSchema is a first FILTER `packages/spec/src/automation/flow-function.zod.ts` stated `selected > 0 AND acted = 0 AND unmeasured = 0` as *the* broken-sweep query, unqualified -- the third `packages/spec` surface carrying the claim, outside the fence of the change that corrected `automation/execution.zod.ts` and `integration/connector.zod.ts`. #12685 measured the A/B on one graph pair through the real engine: a healthy idempotent sweep -- re-select the same records, gate each one on "already handled" -- and a dead gate BOTH report `selected > 0, acted 0, unmeasured 0`. "Over N consecutive runs" does not rescue it: the healthy steady state is persistent for as long as the outstanding work stands, so it trips on every run; consecutiveness filters flapping, which is a different failure. Reworded to the shape the sibling surfaces now agree on: the predicate is the FIRST FILTER, not a verdict, and the per-node fold (`FlowRunSummary.nodes[]` / `gates[]`) is the discriminator. Each clause keeps its own true point -- a declared-`writes` function makes `acted` INCOMPLETE rather than zero, and an under-reported `selected` can still only make the filter quieter, never wrong. This file's `@module` block is emitted verbatim as the intro of `content/docs/references/automation/flow-function.mdx`, so the artifact is regenerated here with `gen:schema && gen:docs` -- never hand-edited. TSDoc and generated-docs prose only -- no behaviour, no schema, no accept-set change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .../spec-flow-function-broken-sweep-filter.md | 46 +++++++++++++++++++ .../references/automation/flow-function.mdx | 16 +++++-- .../spec/src/automation/flow-function.zod.ts | 39 ++++++++++++---- 3 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 .changeset/spec-flow-function-broken-sweep-filter.md diff --git a/.changeset/spec-flow-function-broken-sweep-filter.md b/.changeset/spec-flow-function-broken-sweep-filter.md new file mode 100644 index 0000000000..b98260c590 --- /dev/null +++ b/.changeset/spec-flow-function-broken-sweep-filter.md @@ -0,0 +1,46 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): `FlowFunctionEffectSchema`'s broken-sweep claim is a first FILTER, not the detector (#12685) + +`patch`, and not empty: these doc blocks ship in `.d.ts`, and this file's +`@module` block is emitted verbatim as the intro of +`content/docs/references/automation/flow-function.mdx` — so the sentence an +operator reads while wiring an alert is a published artifact of this package, +regenerated in this change. No schema, no accept set, no runtime behaviour +moves. + +## The wrong claim + +`packages/spec/src/automation/flow-function.zod.ts` stated +`selected > 0 AND acted = 0 AND unmeasured = 0` as *the* broken-sweep query, +unqualified — the third `packages/spec` surface carrying it, outside the fence +of the change that corrected `automation/execution.zod.ts` and +`integration/connector.zod.ts`. #12685 measured the A/B on one graph pair +through the real engine (pinned in `run-summary.test.ts`): a healthy idempotent +sweep — re-select the same records, gate each one on "already handled" — and a +dead gate BOTH report `selected > 0, acted 0, unmeasured 0`. "Over N +consecutive runs" does not rescue it: the healthy steady state is persistent +for as long as the outstanding work stands, so it trips on every run; +consecutiveness filters flapping, which is a different failure. + +The failure mode is silent. An operator who wires an alert to the documented +predicate watches it fire during normal operation and mutes it, leaving a dead +sweep unmonitored *while looking monitored*. + +## What the prose says now + +The predicate is stated as the FIRST FILTER and the per-node fold +(`FlowRunSummary.nodes[]` / `gates[]`) is named as the discriminator — the same +shape `sys_automation_run`'s field descriptions, `content/docs/automation/ +flows.mdx`, `automation/execution.zod.ts` and `integration/connector.zod.ts` +now agree on. Each clause keeps its own true point: a declared-`writes` +function makes `acted` INCOMPLETE rather than zero, which is why +`unmeasuredEffect` keeps the first filter off the flows that call one, and an +under-reported `selected` can still only make the filter quieter, never wrong. + +Five mentions in the file were triaged individually; the one that reads +"an under-report reads exactly like the broken sweep #4354 exists to detect" +was left standing, because it claims nothing about the three-clause predicate +and stays true. diff --git a/content/docs/references/automation/flow-function.mdx b/content/docs/references/automation/flow-function.mdx index a85bf0ddc4..ab9f4d51c6 100644 --- a/content/docs/references/automation/flow-function.mdx +++ b/content/docs/references/automation/flow-function.mdx @@ -40,10 +40,18 @@ relying on. It is now declared in two halves: ## What is deliberately not here A blanket `unmeasuredEffect` on every `script` step (the escape hatch #4354 -gave `connector_action`) was rejected: it would suppress the broken-sweep -signal on every flow that calls any function, in order to accommodate the -flows that break the rule — paying for a rule-breaker with everyone else's -signal, and fossilizing the violation as supported behaviour. +gave `connector_action`) was rejected: it would drop every flow that calls +any function out of the broken-sweep FIRST FILTER +(`selected > 0 AND acted = 0 AND unmeasured = 0`), in order to accommodate +the flows that break the rule — paying for a rule-breaker with everyone +else's measurement, and fossilizing the violation as supported behaviour. +That predicate is a first filter and not a verdict (#12685: a healthy +idempotent sweep that re-selects the same records and gates each one on +"already handled" matches it on every run too, and what discriminates is the +per-node fold in `FlowRunSummary.nodes[]` / `gates[]`). Dropping out of it is +still costly even so: a run the filter never selects is never folded over +either, so the escape hatch is declared per function rather than granted to +every `script` step. Nor is this *enforcement*. The runtime hands a function no data reach — `FlowFunctionContext` in `@objectstack/service-automation` carries diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts index 6e3a7735c8..0d2bcc24a7 100644 --- a/packages/spec/src/automation/flow-function.zod.ts +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -36,10 +36,18 @@ * ## What is deliberately not here * * A blanket `unmeasuredEffect` on every `script` step (the escape hatch #4354 - * gave `connector_action`) was rejected: it would suppress the broken-sweep - * signal on every flow that calls any function, in order to accommodate the - * flows that break the rule — paying for a rule-breaker with everyone else's - * signal, and fossilizing the violation as supported behaviour. + * gave `connector_action`) was rejected: it would drop every flow that calls + * any function out of the broken-sweep FIRST FILTER + * (`selected > 0 AND acted = 0 AND unmeasured = 0`), in order to accommodate + * the flows that break the rule — paying for a rule-breaker with everyone + * else's measurement, and fossilizing the violation as supported behaviour. + * That predicate is a first filter and not a verdict (#12685: a healthy + * idempotent sweep that re-selects the same records and gates each one on + * "already handled" matches it on every run too, and what discriminates is the + * per-node fold in `FlowRunSummary.nodes[]` / `gates[]`). Dropping out of it is + * still costly even so: a run the filter never selects is never folded over + * either, so the escape hatch is declared per function rather than granted to + * every `script` step. * * Nor is this *enforcement*. The runtime hands a function no data reach — * {@link FlowFunctionContext} in `@objectstack/service-automation` carries @@ -62,12 +70,25 @@ import { strictObject } from '../shared/strict-object'; * - `'writes'` — it performs writes, or dispatches effects, that the platform * cannot see or count. The step reports * `ExecutionStepMetrics.unmeasuredEffect`, so the run's `unmeasured` tally - * keeps the broken-sweep query + * keeps the broken-sweep FIRST FILTER * (`selected > 0 AND acted = 0 AND unmeasured = 0`) off it — and only off - * the flows that actually call such a function. + * the flows that actually call such a function. The third clause earns its + * place for the reason it always did: such a run has an INCOMPLETE `acted` + * count, not a zero one. + * + * A filter, not a verdict (#12685). Even with all three clauses the predicate + * cannot separate a broken sweep from a healthy idempotent one: a sweep that + * re-selects the same records and gates each one on "already handled" matches + * it on every run while that work stands, so "over N consecutive runs" does + * not separate the two either — consecutiveness filters flapping, which is a + * different failure. What separates them is the per-node fold on + * `FlowRunSummary` (`nodes[]` / `gates[]`): a healthy skip is accounted for by + * a read the run performed, while a dead gate skips just as often with nothing + * behind it. Declaring `'writes'` decides which runs enter that first filter; + * it does not make the filter a detector. * * There is deliberately no `'reads'` member: an under-reported `selected` can - * only make the broken-sweep alert quieter, never wrong, so declaring it would + * only make the broken-sweep filter quieter, never wrong, so declaring it would * buy nothing the runtime acts on. */ export const FlowFunctionEffectSchema = lazySchema(() => z.enum([ @@ -120,7 +141,7 @@ export const DEFAULT_FLOW_FUNCTION_EFFECT: FlowFunctionEffect = 'pure'; * construction, so before this change a misspelled `effect` was dropped at the * schema and then *not looked for* by the reader — and the failure is the quiet * direction: the function is registered, it runs, and its writes are counted as - * none, so #4354's broken-sweep query stays silent on the one flow that needed + * none, so #4354's broken-sweep filter stays silent on the one flow that needed * it. (A misspelled `effect` VALUE — `'write'` — already fails loudly-ish: * `normalizeFlowFunctionEntry` degrades it to `'writes'` and surfaces the raw * string. A misspelled KEY had no such backstop.) @@ -152,7 +173,7 @@ export const FlowFunctionDeclarationSchema = lazySchema(() => strictObject({ 'Until this shape was closed, these were dropped silently — and `normalizeFlowFunctionEntry` reads only ' + '`handler`/`effect` by construction, so a misspelled `effect` was discarded twice over: ' + 'the function still registered, still ran, and its writes were still counted as none, ' + - 'which is what keeps the broken-sweep alert quiet on the run that needed it.', + 'which is what keeps the broken-sweep filter quiet on the run that needed it.', }, { handler: z.function().describe('The function invoked by name (a `script` node, a string-named Hook/Action handler)'), effect: FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)