diff --git a/.changeset/broken-sweep-residue-first-filter.md b/.changeset/broken-sweep-residue-first-filter.md new file mode 100644 index 0000000000..1c37193e0c --- /dev/null +++ b/.changeset/broken-sweep-residue-first-filter.md @@ -0,0 +1,35 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): the last three broken-sweep mentions in `automation` become the first FILTER, and the misspelled-`effect` sentences state the direction the code measures (#13063) + +#12685 measured that `selected > 0 AND acted = 0 AND unmeasured = 0` cannot separate a +healthy idempotent sweep from a dead gate, and #12721 / #12722 / #12900 / #13068 rewrote +the surfaces that stated it as a detector. Each of those changes was fenced to the doc +blocks its dispatch named, so three mentions inside `packages/spec/src/automation` +survived — including one that ships in `.d.ts` and is what a platform author reads. + +Reworded to the shape the sibling surfaces now agree on: the predicate is the FIRST +FILTER and not a verdict, the per-node fold (`FlowRunSummary.nodes[]` / `gates[]`) is the +discriminator, and each clause keeps its own true point. + +- `ExecutionStepMetricsSchema`'s `unmeasuredEffect` rationale no longer says an + understated `0` "fires the broken-sweep alert on a healthy run until operators learn to + ignore it". That muting is not peculiar to an understated `0` — after #12685 the filter + selects every healthy idempotent sweep — so the block now states what a fabricated + count actually costs: an understated `0` puts a run that DID act inside the filter, an + overstated `1` keeps a run that acted on nothing outside it, and a faked `acted` is a + fact the per-node fold can only repeat rather than settle. + +Separately, and measured rather than ruled: the two sentences describing a misspelled +`effect` key stated their consequence backwards. Read forward, a lost `effect: 'writes'` +declaration means the `script` step reports no `unmeasuredEffect`, so the run reports +`selected > 0, acted 0, unmeasured 0` — which SATISFIES the filter. The run therefore +lands INSIDE the candidate set reading exactly like a dead sweep, rather than escaping +it; the declaration is what would have kept it out. That is how the same file's `@module` +block already stated it, and what `flow-function-effect.dogfood.test.ts` asserts end to +end. The `FlowFunctionDeclarationSchema` TSDoc and the author-facing `history` string in +its unknown-key message now say so. + +TSDoc and one error-message string only — no behaviour, no schema, no accept-set change. diff --git a/packages/spec/src/automation/execution.test.ts b/packages/spec/src/automation/execution.test.ts index c24d8cecf5..2aae600120 100644 --- a/packages/spec/src/automation/execution.test.ts +++ b/packages/spec/src/automation/execution.test.ts @@ -208,8 +208,13 @@ describe('FlowRunSummarySchema', () => { }); it('carries an uncountable-effect tally distinct from acted', () => { - // A connector-driven run: `acted: 0` is INCOMPLETE, not zero, and the - // broken-sweep query has to be able to tell. + // A connector-driven run: `acted: 0` is INCOMPLETE, not zero. The third + // clause of the broken-sweep FIRST FILTER + // (`selected > 0 AND acted = 0 AND unmeasured = 0`) is what reads that + // distinction, which is why the tally is carried separately instead of + // folded into `acted`. The filter only selects candidates — a healthy + // idempotent sweep satisfies it too, and the per-node fold discriminates + // (#12685) — but merging the two would corrupt even the selection. const summary = FlowRunSummarySchema.parse({ selected: 9, acted: 0, skipped: 0, unmeasured: 3, nodes: [{ nodeId: 'push', nodeType: 'connector_action', status: 'success' as const, runs: 3, failures: 0, skipped: 0, unmeasured: 3 }], diff --git a/packages/spec/src/automation/execution.zod.ts b/packages/spec/src/automation/execution.zod.ts index f30cc43573..a15a5ab7ee 100644 --- a/packages/spec/src/automation/execution.zod.ts +++ b/packages/spec/src/automation/execution.zod.ts @@ -55,10 +55,19 @@ export type ExecutionStatus = z.input; * And `unmeasuredEffect` is the third answer, which a two-counter model would * have had to fake: a `connector_action` reaches an external system, and when * the action declares nothing about whether it reads or writes, `0` understates - * a write and `1` overstates a read. Both are worse than saying so — an - * understated `0` fires the broken-sweep alert on a healthy run until operators - * learn to ignore it, and an overstated `1` makes the alert never fire at all, - * which is the original bug back again. + * a write and `1` overstates a read. Both are worse than saying so, and in + * opposite directions — an understated `0` puts a run that DID act inside the + * broken-sweep FIRST FILTER (`selected > 0 AND acted = 0 AND unmeasured = 0`), + * and an overstated `1` keeps a run that acted on nothing outside it, which is + * the original bug back again. + * + * Being inside that filter is not the accusation an alarm reading makes of it: + * it 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" + * satisfies it on every run too, and what separates the two is the per-node + * fold spelled out on `FlowRunSummarySchema` below. That is also what makes a + * fabricated count the expensive kind of wrong: the fold is the step that would + * have settled it, and a faked `acted` is a fact the fold can only repeat. * * [#4395] "Declares nothing" is now the fallback rather than the only case: * `ConnectorActionSchema.effect` lets an action say `read` or `write`, and a diff --git a/packages/spec/src/automation/flow-function.test.ts b/packages/spec/src/automation/flow-function.test.ts index 75685d7f9c..13ba40e3cc 100644 --- a/packages/spec/src/automation/flow-function.test.ts +++ b/packages/spec/src/automation/flow-function.test.ts @@ -191,7 +191,11 @@ describe('defineStack({ functions }) — the authoring surface (#4396)', () => { // ignores everything else by construction, so a misspelled `effect` was // dropped at the schema and then not looked for — and the failure is the quiet // direction: the function registers, runs, and its writes are counted as none, -// keeping #4354's broken-sweep query silent on the one run that needed it. +// so the run reports `selected > 0, acted 0, unmeasured 0` — which SATISFIES +// #4354's broken-sweep FIRST FILTER. The run lands in the candidate set +// reading exactly like a dead sweep, on a flow that did its work; the `effect` +// declaration is what would have kept it out (`unmeasured > 0`), and the +// misspelling is what dropped it. describe('unknown keys are rejected, not stripped (#4001 batch 11)', () => { const base = { manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const }, diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts index 0d2bcc24a7..2435835195 100644 --- a/packages/spec/src/automation/flow-function.zod.ts +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -141,8 +141,12 @@ 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 filter stays silent on the one flow that needed - * it. (A misspelled `effect` VALUE — `'write'` — already fails loudly-ish: + * none, so the run reports `selected > 0, acted 0, unmeasured 0` and lands + * INSIDE #4354's broken-sweep first filter, reading exactly like the broken + * sweep that filter exists to detect — on the one flow that was doing its work + * through the function. Declaring `effect: 'writes'` is what would have kept it + * out (`unmeasured > 0`); the misspelling is what dropped the declaration. + * (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.) */ @@ -173,7 +177,8 @@ 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 filter quiet on the run that needed it.', + 'which is what drops the run INTO the broken-sweep first filter ' + + '(`selected > 0 AND acted = 0 AND unmeasured = 0`) on the very flow whose work the declaration would have accounted for.', }, { 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)