Skip to content

Commit 8b67272

Browse files
os-trumpclaude
andauthored
docs(automation): record why failed has no column on sys_automation_run, and name it in summary_json (#16759)
* docs(automation): record why `failed` has no column on `sys_automation_run`, and name it in `summary_json` For #15606, decision batch #76 (2026-09-07): option 2 — `failed` stays in the `summary_json` blob, and the schema says why. `FlowRunSummary` carries five run-level totals; four of them have a column on `sys_automation_run` and `failed` does not. The four are columns because ONE filter expression needs them in ONE row — `selected_count > 0 AND acted_count = 0`, qualified by `unmeasured_count` — and a WHERE clause cannot reach into a JSON blob for an operand. `failed` is not one of its operands: it would be its own predicate, nobody alerts on it today, and a caller that wants it has already fetched `summary_json`. The reasoning goes in the comment above `selected_count` — the same paragraph that explains why the four ARE columns, and therefore the paragraph a reader is already in when they notice the fifth is not — together with the re-open condition: the first real need to ALERT on "which runs lost rows this week" is the card that adds `failed_count`, mirroring `unmeasured_count` (null on rows written before the column existed, never `0`), a one-column change on an ADR-0103 engine-owned object and a human-floor one. `summary_json`'s description now names `failed` as the field to read lost-row counts from, and `sys-automation-run-failed-count-verdict.test.ts` pins both halves — no `failed_count` (or any other `fail`-named) column, and a description that still names `failed` — so the explanation cannot rot into a claim the schema no longer supports. The terminal-row write in `ObjectStoreSuspendedRunStore`, where a fifth `record.summary?.failed ?? null` line would go, points at that verdict so it is not re-derived from the write site either. No schema shape moves: no field added, removed or renamed, no type or `required` flag changed, and every accepted set is byte-for-byte what it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs(automation): record the measured evidence that the description ships The changeset asserted the `summary_json` description reaches the published bundle; it now states HOW that was measured rather than asserting it, because that measurement is the whole reason this diff is not `skip-changeset`: `SysAutomationRun` is re-exported from `src/index.ts`, `package.json` publishes `files: ["dist"]`, and after a real build the new text is present in both `dist/index.js` and `dist/index.cjs`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8333a6c commit 8b67272

4 files changed

Lines changed: 168 additions & 1 deletion

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
docs(automation): `sys_automation_run` says why `failed` has no column of its own, and `summary_json` names it (#15606)
6+
7+
`FlowRunSummary` carries five run-level totals. Four of them —
8+
`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count` — have a
9+
column on `sys_automation_run`; `failed` rides inside the `summary_json` blob.
10+
That asymmetry was filed as a finding and ruled on (decision batch #76,
11+
2026-09-07) rather than closed by adding a fifth column, and this change is the
12+
ruling: the reasoning now ships in the schema instead of living only on the
13+
card.
14+
15+
The four are columns because ONE filter expression needs them in ONE row —
16+
`selected_count > 0 AND acted_count = 0`, qualified by `unmeasured_count` — and
17+
a `WHERE` clause cannot reach into a JSON blob for an operand, so every operand
18+
of that expression has to be a column or the expression cannot be written at
19+
all. `failed` is not one of its operands: it would be its own predicate
20+
(`failed_count > 0`), nobody alerts on it today, and a caller that wants it has
21+
already fetched `summary_json`.
22+
23+
What a consumer sees change:
24+
25+
- `summary_json`'s `description` now names `failed` as the field to read
26+
lost-row counts from, states that the run-level totals live in the blob
27+
alongside the per-node breakdown, and repeats the `unmeasured`/`failed`
28+
convention that an absent count means "not tracked", never zero. ⚠️ This is
29+
why the change carries a changeset and NOT `skip-changeset`, and it was
30+
MEASURED rather than assumed from "it's only prose": `SysAutomationRun` is
31+
re-exported from `src/index.ts`, `package.json` publishes `files: ["dist"]`,
32+
and after `pnpm --filter @objectstack/service-automation build` the new
33+
description text is present in BOTH published entry points — one hit each in
34+
`dist/index.js` and `dist/index.cjs`. `skip-changeset` is for a diff that
35+
publishes nothing from any released package; this one changes bytes inside a
36+
released package's shipped bundle, so it does not qualify. (`description` is
37+
also what the authorable `help` / `helpText` keys alias onto in
38+
`packages/spec/src/data/object.zod.ts` — documentation a consumer surface can
39+
render, not an internal note.)
40+
- The comment above `selected_count` — the paragraph that explains why the
41+
four are columns, and therefore the paragraph a reader is in when they
42+
notice the fifth is not — now carries the verdict for `failed` and the one
43+
condition that re-opens it: the first real need to ALERT on "which runs lost
44+
rows this week" is the card that adds `failed_count`, mirroring
45+
`unmeasured_count` (null on rows written before the column existed, never
46+
`0`) — one column on an ADR-0103 engine-owned object, a human-floor change.
47+
- `ObjectStoreSuspendedRunStore`'s terminal-row write, where a fifth
48+
`record.summary?.failed ?? null` line would go, points at that verdict so the
49+
question is not re-derived from the write site either.
50+
51+
No schema shape moves: no field is added, removed or renamed, no type or
52+
`required` flag changes, and the accepted set of every object and payload is
53+
byte-for-byte what it was. `sys-automation-run-failed-count-verdict.test.ts`
54+
pins both halves — that there is still no `failed_count` (or any other
55+
`fail`-named) column, and that `summary_json`'s description still names
56+
`failed` — so the explanation cannot rot into a claim the schema no longer
57+
supports.

packages/services/service-automation/src/suspended-run-store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,12 @@ export class ObjectStoreSuspendedRunStore implements SuspendedRunStore {
662662
// rides in the JSON blob. Null (not 0) when the engine computed no
663663
// summary: "not measured" and "measured zero" are different answers, and
664664
// only one of them should trip an alarm.
665+
//
666+
// [#15606] Four counters, not five: `summary.failed` stays in the blob
667+
// on purpose and has no column to write it to. ⛔ Do not add
668+
// `failed_count: record.summary?.failed ?? null` here as a tidy-up — the
669+
// verdict, and the one condition that re-opens it, are written above
670+
// `selected_count` in `sys-automation-run.object.ts`. Read that first.
665671
selected_count: record.summary?.selected ?? null,
666672
acted_count: record.summary?.acted ?? null,
667673
skipped_count: record.summary?.skipped ?? null,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 DELIBERATE "no `failed_count` column" verdict
8+
* (#15606, decision batch #76: option 2, `failed` stays in the blob).
9+
*
10+
* `FlowRunSummary` carries five run-level totals. Four of them
11+
* (`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count`) have a
12+
* column on this object; `failed` does not. That asymmetry was filed as a
13+
* finding and ruled on rather than fixed: the four are columns because ONE
14+
* filter expression needs them in ONE row — `selected_count > 0 AND
15+
* acted_count = 0`, qualified by `unmeasured_count` — and a WHERE clause cannot
16+
* reach into a JSON blob for an operand. `failed` is not an operand of that
17+
* expression; it would be its own predicate (`failed_count > 0`), nobody alerts
18+
* on it today, and a caller that wants it has already fetched `summary_json`.
19+
*
20+
* This file is what stops the verdict from being an absence nobody can see. The
21+
* reasoning itself lives where the next reader meets it — in the comment above
22+
* `selected_count`, the same paragraph that provokes the question — and this
23+
* pin holds the SCHEMA half of it honest:
24+
*
25+
* - the asymmetry it explains is still real (four columns, no fifth), so the
26+
* prose cannot outlive its subject;
27+
* - `summary_json`'s description still NAMES `failed` as the place to read
28+
* lost-row counts, so "read it from the blob" does not decay into a blob
29+
* with no documented way in.
30+
*
31+
* ⛔ Do not "fix" a failure here by deleting the assertion. Two legitimate ways
32+
* to turn this file red, and both are edits to the verdict, not to the pin:
33+
* add `failed_count` (the re-open condition — a real need to ALERT on "which
34+
* runs lost rows", null on old rows and never `0`, mirroring `unmeasured_count`;
35+
* one column on an ADR-0103 engine-owned object, human floor), or rewrite the
36+
* `summary_json` description — in which case it still has to name `failed`.
37+
*/
38+
describe('sys_automation_run — `failed` stays in the blob (#15606 verdict)', () => {
39+
const fields = SysAutomationRun.fields as Record<string, Record<string, unknown>>;
40+
41+
it('carries the four counters that a single filter expression needs in one row', () => {
42+
// Positive control for the absence assertion below: these four read back
43+
// through the SAME accessor path, so `failed_count === undefined` is a
44+
// measurement of the schema and not of a typo'd lookup.
45+
for (const name of ['selected_count', 'acted_count', 'skipped_count', 'unmeasured_count']) {
46+
expect(fields[name], `${name} is expected to be a column`).toBeDefined();
47+
expect(fields[name].type).toBe('number');
48+
}
49+
});
50+
51+
it('declares no `failed_count` column — the verdict itself', () => {
52+
expect(fields.failed_count).toBeUndefined();
53+
// Guard the spelling too: a `failed`/`failures` column landing under any
54+
// other name is the same stored-surface change and needs the same ruling.
55+
const match = (names: string[]) => names.filter((name) => /fail/i.test(name));
56+
// Positive control, so the empty result below is a measurement: the same
57+
// matcher over the same key list plus the name the re-open condition would
58+
// add does fire.
59+
expect(match([...Object.keys(fields), 'failed_count'])).toEqual(['failed_count']);
60+
expect(match(Object.keys(fields))).toEqual([]);
61+
});
62+
63+
it('`summary_json` description NAMES `failed` as the place to read lost-row counts', () => {
64+
const description = fields.summary_json?.description;
65+
expect(typeof description).toBe('string');
66+
// The load-bearing token: the blob is only a usable answer to "which runs
67+
// lost rows?" if the field that answers it is named here by the name a
68+
// caller will find in the parsed JSON.
69+
expect(description as string).toContain('`failed`');
70+
});
71+
72+
it('does not hide the counter from the run row by promoting a phantom column into the highlight set', () => {
73+
// `highlightFields` is the operator-facing surface of this object; if a
74+
// later edit lists `failed_count` there, the column verdict has moved and
75+
// the prose above `selected_count` is stale.
76+
expect(SysAutomationRun.highlightFields).not.toContain('failed_count');
77+
expect(SysAutomationRun.highlightFields).toContain('acted_count');
78+
});
79+
});

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,31 @@ export const SysAutomationRun = ObjectSchema.create({
330330
// the failure mode is silent: a detector that fires during normal operation
331331
// gets muted, and a muted broken-sweep detector is the same silence #4347
332332
// produced — except it now looks monitored.
333+
//
334+
// [#15606] And `failed` deliberately does NOT get one — the asymmetry
335+
// below is a decision, not an oversight, recorded here because this is the
336+
// paragraph that provokes the question. The four counters exist because
337+
// ONE filter expression needs them in ONE row: `selected_count > 0 AND
338+
// acted_count = 0`, qualified by `unmeasured_count`. A WHERE clause cannot
339+
// reach into a JSON blob for an operand, so every operand of that one
340+
// expression has to be a column or the expression cannot be written at
341+
// all. `failed` is not one of its operands. It would be its OWN predicate
342+
// (`failed_count > 0`), nobody alerts on it today, and a caller that wants
343+
// it has already fetched `summary_json` — where the fold
344+
// `failed = Σ nodes[].failures` sits, and which says so in its own
345+
// description (pinned by `sys-automation-run-failed-count-verdict.test.ts`,
346+
// so this explanation cannot rot into a lie about the schema).
347+
//
348+
// Re-open condition, stated so it is not a matter of taste later: the
349+
// FIRST real need to ALERT on "which runs lost rows this week" is the card
350+
// that adds `failed_count`, mirroring `unmeasured_count` — null on rows
351+
// written before the column existed, ⛔ never `0`, because "not tracked"
352+
// and "nothing failed" are different answers and only one of them should
353+
// stay quiet. One column on an ADR-0103 engine-owned object: a human-floor
354+
// change, never a rider. Until such a need is named, "readable but not
355+
// queryable" is the right trade for this one counter — which is the same
356+
// sentence as the first paragraph above, reaching the opposite conclusion
357+
// because the input differs: there, an alarm existed to serve.
333358
selected_count: Field.number({
334359
label: 'Records Selected',
335360
required: false,
@@ -361,7 +386,7 @@ export const SysAutomationRun = ObjectSchema.create({
361386
summary_json: Field.textarea({
362387
label: 'Run Summary',
363388
required: false,
364-
description: 'JSON per-node breakdown (terminal status, runs, failures, selected/acted) plus which gates closed and how often. Folded from the FULL step log, so its counts stay exact even when `steps_json` is compacted.',
389+
description: 'JSON per-node breakdown (terminal status, runs, failures, selected/acted) plus which gates closed and how often — AND the run-level totals, which is where `failed` lives: the count of node executions that failed (`failed = Σ nodes[].failures`, contained ones included, so a green run can carry a non-zero `failed`). READ LOST-ROW COUNTS FROM HERE — `failed` has no column of its own, deliberately (see the comment above `selected_count`), so "which runs lost rows?" is answered by parsing this blob, not by a WHERE clause. Absent `failed` = not tracked (an older run), which is not the same as zero. Folded from the FULL step log, so its counts stay exact even when `steps_json` is compacted — and the totals survive this blob\'s own compaction too, which drops only the per-node detail (`detailOmitted`).',
365390
group: 'Outcome',
366391
}),
367392

0 commit comments

Comments
 (0)