Skip to content

Commit 1b99388

Browse files
fix(service-automation): the run row's highlight set carries the first filter's qualifier, unmeasured_count (#22632)
Part of #22590 Clause-②: no Item 2 of #22590 (a flow run's summary reports `acted: 0` while the run sent notifications), under the amended claim (seat review `6094568255`, claim amendment `6094572037`). Item 3, the activity pre-scan, is not addressed here, and #22590 remains open. ## What changes `packages/services/service-automation/src/sys-automation-run.object.ts`, metadata only: 1. `highlightFields` gains `unmeasured_count`, right after `acted_count`. The run row now shows all three operands of the broken-sweep first filter: `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0`. 2. The comment above `highlightFields` names those three operands, and says why the qualifier has to be visible. 3. `unmeasured_count`'s description lists a notification queued for delivery among the uncountable effects, beside the `connector_action` and the mutating `http` call it already named. The rest of the sentence is unchanged. Plus one pin, `src/sys-automation-run-first-filter-highlight.test.ts`, and a `patch` changeset. No column is added (ADR-0103 engine-owned object). No counter moves, and nothing in `notify-node.ts`, `service-messaging` or `packages/spec` changes. ## The premise measurement: no producer under-reports The dispatched premise was this: a notify-only run triggered at `POST /api/v1/automation/FLOW/trigger` returns `acted: 0` AND `unmeasured: 0` while the inbox rows are already written. **It is false.** How it was measured: - A real `bootStack` stack (`@objectstack/verify`), sqlite-wasm, with an org-bound admin session. - The probe was a scratch file and was never committed. - `origin/main` was `514bf3c101`. - The flow is `start → notify (recipients: [admin], channels: [inbox]) → end`, run once as `autolaunched` and once as `screen`. Both answered identically. | composition | door `summary` (selected / acted / unmeasured) | `sys_automation_run` row | `emit()` answer (delivered / enqueued / failed) | notify node `metrics` | `sys_inbox_message` at response | after settle | |---|---|---|---|---|---|---| | **served default**: `requires: ['messaging']`, so `MessagingServicePlugin` defaults, reliable delivery on, as `serve` composes it | 1 / 0 / 1 | 1 / 0 / 1 | 0 / 1 / 0 | `selected 1, unmeasuredEffect true` | **0 rows** (response at 100 ms and 39 ms) | 1 row (at 154 ms and 92 ms) | | inline P0: `reliableDelivery: false` | 1 / **1** / 0 | 1 / 1 / 0 | 1 / 0 / 0 | `selected 1, acted 1` | 1 row | 1 row | | control, served: audience `role:no_such_role_22590` resolves to no user | 1 / 0 / 0 | 1 / 0 / 0 | 0 / 0 / 0 | `selected 1, acted 0` | 0 | 0 (5 s deadline) | | control, inline | 1 / 0 / 0 | 1 / 0 / 0 | 0 / 0 / 0 | `selected 1, acted 0` | 0 | 0 | On a served stack, the in-app write really is asynchronous: the outbox dispatcher writes the inbox row about 50 ms after the response. The contract's answer for that is `unmeasuredEffect` ("an effect I cannot count"), never a fabricated `acted`, and it already reaches both the door's summary and the run row. The delivering run (`1 / 0 / 1`) sits outside the first filter. The nothing-to-do control (`1 / 0 / 0`) sits inside it, as designed. `summarizeRun` folds `step.metrics` from every step whatever its `nodeType`, so the count already reads from each node's outcome; there is no list of record-writing kinds. **Release parity.** - The `@objectstack/service-automation@17.7.0` tag is an ancestor of HEAD (`merge-base --is-ancestor`, exit 0). - These are byte-identical at the 17.7.0 tags and at HEAD (`diff`, exit 0): - the notify node's metrics block; - `MessagingService.emit`'s outbox and inline tail. - `run-summary.ts` and `messaging-service-plugin.ts` have no diffstat since the tag. - `reliableDelivery: true` is the default at the tag. - The trigger door relays the engine result, `summary` included, at `@objectstack/runtime@17.7.0`. - So the card's source, measured on 17.7.0, saw `acted 0` beside an `unmeasured 1` it did not read. ## Why the fix is the highlight set, not a counter At the API, the delivering run is already distinguishable. The misread survives on the Runs surface. `highlightFields` carried `selected_count` and `acted_count` but not their qualifier, although the object's own comment says the first filter "has to be visible on the run row itself … a signal you must click to find is a signal nobody sees". So a served stack's delivering notify sweep showed as `selected 1, acted 0` on the run row, which is the card's misread made on another surface. Counting the enqueue as `acted` would bring back the #7747 defect: a summary asserting a delivery that `sys_notification_delivery` could still mark `dead`. Folding it into anything other than `unmeasured` would break the contract in `execution.zod.ts`. The fix that matches the declared design is to show the qualifier the design already has. ## Node-kind census (no edits) These are the effect-bearing node kinds and the counter each reports on `main`: - `create_record`, `update_record`, `delete_record`: `acted` as a row count. - `get_record`: `selected`. - `http`: `acted 0` for a read, `unmeasuredEffect` for a mutating call. - `connector_action`: `acted 1` for an accepted declared write, `acted 0` for a declared read, `unmeasuredEffect` for an undeclared action or a failed write dispatch. - `script`: `unmeasuredEffect` for a function declared `writes`, nothing for a pure one. - `notify`: `acted` equal to `delivered`; `unmeasuredEffect` while a delivery is enqueued; `acted 0` plus `selected` for a zero dispatch. - `subflow`, `map`: the child run rolls up. The kinds without an effect report nothing, which is correct: `decision`, `assignment`, `loop`, `parallel`, `try_catch`, `wait`, `screen`. The one effect-bearing kind with no counter is plugin-approvals' `approval` / `approval_revise` node. See Acceptance notes. ## The pin and its ablation `sys-automation-run-first-filter-highlight.test.ts`: - each first-filter operand is a declared `number` column, a positive control so that the containment check cannot pass on a typo; - `highlightFields` contains all three operands. Ablation, run on the committed tree with `node scripts/ablation-replace.mjs` in WRAP mode under the verify lock, which proves the write landed on disk and restores it under a trap. - Attempt 1 used `--replacement "'acted_count', "`. **The tool refused it before any test ran.** The replacement is a substring of the anchor, so its count could not rise, and the tool reads that as a mutation that did not land. The file was restored to the HEAD blob. Recorded as a no-op. - Attempt 2 used `--anchor " 'unmeasured_count'," --delete`: - the anchor count went 1 → 0, and the blob went `5a0f59cde967` → `ec9f7ec995f1`; - **pin red**: `Tests 1 failed | 1 passed (2)`, `first-filter operands missing from highlightFields: unmeasured_count: expected [ 'unmeasured_count' ] to deeply equal []`; - the column-type test stayed green, as predicted; - restore: the blob is back to `5a0f59cde967`, equal to HEAD, `git diff HEAD` is empty, and `git status --porcelain` is empty. - The pin imports the object by relative path (source), so no `dist/` sits between the mutation and the run. ## Verification Every run below is at HEAD `29cfa41a1b` (the branch merged with `origin/main` `83b8b80728`). Heavy runs went through `scripts/pm/os-verify-lock.sh` (slot `issue-22590`, 3 GB heap, turbo `--concurrency=1`, vitest `--maxWorkers=2`). - **Build**: `turbo run build --filter='@objectstack/service-automation...'` gave `VERDICT command-exit 0`, `Tasks 30 successful, 30 total`. `packages/spec` moved on `main`'s side of the merge, so `pnpm --filter @objectstack/spec check:generated` also ran: `All 15 generated artifacts are up to date`. - **`service-automation` test**: `vitest run --maxWorkers=2` gave `Test Files 182 passed (182)`, `Tests 2287 passed (2287)`. The new pin is included. - **`service-automation` typecheck**: `tsc --noEmit && pnpm check:test-typecheck` gave `VERDICT command-exit 0` and `check:test-typecheck: OK`. The new test file is in tsc's program (`tsc --listFiles` count 1). - **Derived gates**: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands from the real diff (3 paths). All were run, each exit captured before any pipe. The reconcile with `--ran`, given exit codes, answered `63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN`. - 62 exited 0, each printing its own pass verdict. - **NOT MEASURED: `check:dual-build-cjs-loads`**, reason: `PREREQUISITE NOT MET`, exit 3. It reads the built output of every publishable package, and 37 have no `dist/` in this worktree, which built only `service-automation`'s closure. This is a declared narrowing: CI's `Lint & Repo Gates` runs it on a full build. The diff changes a string array and a description string in one source file, and does not touch any package's emit format or exports. - **i18n** (not derived, run because a description changed): `pnpm check:i18n` gave `OK (9 package(s) — all bundles in sync)`, and `pnpm check:i18n-coverage` gave `OK (13 config(s) … none new)`. No translation bundle carries `sys_automation_run`, so nothing was regenerated. - **Scope**: `packages/cli` and `objectql` are not touched; no integration layer is owed. ## Acceptance notes - **The approval-node census finding was dropped by the seat** (seat review `6094568255`). `approval` / `approval_revise` (`packages/plugins/plugin-approvals/src/approval-node.ts`, `approval-revise-node.ts`) open an approval request and suspend with no `metrics`. No door gives a wrong summary, and an approval-only run reads `selected 0`, outside the filter regardless. This was a source reading only, and it is not filed. - For flow operators (the card's source included): read the triple (`selected`, `acted`, `unmeasured`), never `acted` alone. On a served stack, a notify-only sweep that delivered reads `acted 0` with `unmeasured 1`. - `failed_count` stays absent (the #15606 verdict). The sibling pin `sys-automation-run-failed-count-verdict.test.ts` still holds `failed_count` out of `highlightFields`, and still requires `acted_count` in it. - The measurement probe (scratch, never committed) and its logs live in the dev's scratch directory, not in the tree. --- _Generated by [Claude Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f368b7e commit 1b99388

3 files changed

Lines changed: 63 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): the `sys_automation_run` row's highlight set now shows `unmeasured_count` beside `selected_count` and `acted_count`. That puts all three operands of the broken-sweep first filter (`selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0`) on the run row itself. The field's description now also lists a notification queued for delivery as an uncountable effect.
6+
7+
Why: on a stack composed as `serve` composes it, reliable delivery is on, so a `notify` step's in-app message is enqueued. The outbox dispatcher writes it after the run settles, and the node reports `unmeasuredEffect`. A notify sweep that did its job therefore reads `acted 0, unmeasured 1`. With only `selected` and `acted` on the row, it read the same as a sweep with nothing to do. No counter changes: an enqueued delivery stays uncountable, never a fabricated `acted`.
8+
9+
For operators: read the triple (`selected`, `acted`, `unmeasured`), never `acted` alone. `acted 0` with `unmeasured` above 0 means "cannot tell", not "did nothing".
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SysAutomationRun } from './sys-automation-run.object.js';
5+
6+
/**
7+
* `sys_automation_run` — the run row shows the WHOLE broken-sweep first filter.
8+
*
9+
* The filter is `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0`
10+
* (#4354, qualified as a filter and not a verdict by #12685). `highlightFields`
11+
* is how the object puts it on the run row itself, and the third operand is the
12+
* one that matters for reading the other two: `acted_count = 0` beside a
13+
* non-zero `unmeasured_count` means "cannot tell", not "did nothing".
14+
*
15+
* Measured on a stack composed as `serve` composes it (#22590): a flow whose
16+
* only effect is a `notify` step answers `selected 1, acted 0, unmeasured 1`,
17+
* because with reliable delivery on the in-app message is enqueued and written
18+
* by the outbox dispatcher after the run settles. With `unmeasured_count` off
19+
* the highlight set, that delivering sweep read `selected 1, acted 0` on the
20+
* run row: the same row as a sweep with nothing to do.
21+
*
22+
* ⛔ Do not "fix" a failure here by narrowing the list below. An operand leaves
23+
* the highlight set only together with the filter expression it belongs to.
24+
*/
25+
const FIRST_FILTER_OPERANDS = ['selected_count', 'acted_count', 'unmeasured_count'] as const;
26+
27+
describe('sys_automation_run — the highlight set carries every operand of the first filter', () => {
28+
const fields = SysAutomationRun.fields as Record<string, Record<string, unknown>>;
29+
30+
it('each operand is a declared number column (so the containment below is not a typo match)', () => {
31+
for (const name of FIRST_FILTER_OPERANDS) {
32+
expect(fields[name], `${name} is expected to be a column`).toBeDefined();
33+
expect(fields[name].type).toBe('number');
34+
}
35+
});
36+
37+
it('highlightFields contains selected_count, acted_count AND the qualifier unmeasured_count', () => {
38+
const highlight = SysAutomationRun.highlightFields ?? [];
39+
const missing = FIRST_FILTER_OPERANDS.filter((name) => !highlight.includes(name));
40+
expect(missing, `first-filter operands missing from highlightFields: ${missing.join(', ')}`).toEqual([]);
41+
});
42+
});

‎packages/services/service-automation/src/sys-automation-run.object.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,17 @@ export const SysAutomationRun = ObjectSchema.create({
7777
displayNameField: 'display_title',
7878
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
7979
titleFormat: '{flow_name} · {node_id}',
80-
// `selected_count`/`acted_count` sit in the highlight set on purpose (#4354):
81-
// "selected 30, acted 0" has to be visible on the run row itself, not one
82-
// drill-down away — a signal you must click to find is a signal nobody sees.
83-
highlightFields: ['flow_name', 'node_id', 'status', 'selected_count', 'acted_count', 'correlation', 'started_at', 'updated_at'],
80+
// The three operands of the broken-sweep first filter —
81+
// `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0` — sit in
82+
// the highlight set on purpose (#4354): "selected 30, acted 0" has to be
83+
// visible on the run row itself, not one drill-down away — a signal you must
84+
// click to find is a signal nobody sees. The qualifier is not optional there:
85+
// `acted_count = 0` beside a non-zero `unmeasured_count` means "cannot tell",
86+
// not "did nothing". On a served stack a notify step's delivery is enqueued
87+
// (reliable delivery on), so a notify sweep that did its job reads
88+
// `acted 0, unmeasured 1` — and with the qualifier off the row it read as a
89+
// sweep with nothing to do (#22590).
90+
highlightFields: ['flow_name', 'node_id', 'status', 'selected_count', 'acted_count', 'unmeasured_count', 'correlation', 'started_at', 'updated_at'],
8491

8592
fields: {
8693
id: Field.text({ label: 'Run ID', required: true, readonly: true, group: 'System' }),
@@ -459,7 +466,7 @@ export const SysAutomationRun = ObjectSchema.create({
459466
unmeasured_count: Field.number({
460467
label: 'Uncountable Effects',
461468
required: false,
462-
description: 'Executions that reached something the platform cannot count (a `connector_action`, a mutating `http` call whose response was lost). The qualifier `acted_count` needs to be trusted: the broken-sweep filter is `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0` (a filter, not a verdict — see `acted_count`), because a run with uncountable effects has an INCOMPLETE acted count, not a zero one. Null on rows written before this was tracked.',
469+
description: 'Executions that reached something the platform cannot count (a `connector_action`, a mutating `http` call whose response was lost, a notification queued for delivery). The qualifier `acted_count` needs to be trusted: the broken-sweep filter is `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0` (a filter, not a verdict — see `acted_count`), because a run with uncountable effects has an INCOMPLETE acted count, not a zero one. Null on rows written before this was tracked.',
463470
group: 'Outcome',
464471
}),
465472

0 commit comments

Comments
 (0)