From 1c5b28829de69b8710a8d8d8c6750ff03a161745 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 03:48:39 +0000 Subject: [PATCH 1/2] docs(automation): record why `failed` has no column on `sys_automation_run`, and name it in `summary_json` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- ...automation-run-failed-stays-in-the-blob.md | 49 ++++++++++++ .../src/suspended-run-store.ts | 6 ++ ...utomation-run-failed-count-verdict.test.ts | 79 +++++++++++++++++++ .../src/sys-automation-run.object.ts | 27 ++++++- 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 .changeset/automation-run-failed-stays-in-the-blob.md create mode 100644 packages/services/service-automation/src/sys-automation-run-failed-count-verdict.test.ts diff --git a/.changeset/automation-run-failed-stays-in-the-blob.md b/.changeset/automation-run-failed-stays-in-the-blob.md new file mode 100644 index 0000000000..e8f07da357 --- /dev/null +++ b/.changeset/automation-run-failed-stays-in-the-blob.md @@ -0,0 +1,49 @@ +--- +"@objectstack/service-automation": patch +--- + +docs(automation): `sys_automation_run` says why `failed` has no column of its own, and `summary_json` names it (#15606) + +`FlowRunSummary` carries five run-level totals. Four of them — +`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count` — have a +column on `sys_automation_run`; `failed` rides inside the `summary_json` blob. +That asymmetry was filed as a finding and ruled on (decision batch #76, +2026-09-07) rather than closed by adding a fifth column, and this change is the +ruling: the reasoning now ships in the schema instead of living only on the +card. + +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, so every operand +of that expression has to be a column or the expression cannot be written at +all. `failed` is not one of its operands: it would be its own predicate +(`failed_count > 0`), nobody alerts on it today, and a caller that wants it has +already fetched `summary_json`. + +What a consumer sees change: + +- `summary_json`'s `description` now names `failed` as the field to read + lost-row counts from, states that the run-level totals live in the blob + alongside the per-node breakdown, and repeats the `unmeasured`/`failed` + convention that an absent count means "not tracked", never zero. This string + ships in the published bundle and is what a Studio/admin surface renders for + the field, which is why this carries a changeset rather than + `skip-changeset`. +- The comment above `selected_count` — the paragraph that explains why the + four are columns, and therefore the paragraph a reader is in when they + notice the fifth is not — now carries the verdict for `failed` and the one + condition that re-opens it: 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`) — one column on an ADR-0103 engine-owned object, a human-floor change. +- `ObjectStoreSuspendedRunStore`'s terminal-row write, where a fifth + `record.summary?.failed ?? null` line would go, points at that verdict so the + question is not re-derived from the write site either. + +No schema shape moves: no field is added, removed or renamed, no type or +`required` flag changes, and the accepted set of every object and payload is +byte-for-byte what it was. `sys-automation-run-failed-count-verdict.test.ts` +pins both halves — that there is still no `failed_count` (or any other +`fail`-named) column, and that `summary_json`'s description still names +`failed` — so the explanation cannot rot into a claim the schema no longer +supports. diff --git a/packages/services/service-automation/src/suspended-run-store.ts b/packages/services/service-automation/src/suspended-run-store.ts index 04ac9f8760..06d6ccf08f 100644 --- a/packages/services/service-automation/src/suspended-run-store.ts +++ b/packages/services/service-automation/src/suspended-run-store.ts @@ -662,6 +662,12 @@ export class ObjectStoreSuspendedRunStore implements SuspendedRunStore { // rides in the JSON blob. Null (not 0) when the engine computed no // summary: "not measured" and "measured zero" are different answers, and // only one of them should trip an alarm. + // + // [#15606] Four counters, not five: `summary.failed` stays in the blob + // on purpose and has no column to write it to. ⛔ Do not add + // `failed_count: record.summary?.failed ?? null` here as a tidy-up — the + // verdict, and the one condition that re-opens it, are written above + // `selected_count` in `sys-automation-run.object.ts`. Read that first. selected_count: record.summary?.selected ?? null, acted_count: record.summary?.acted ?? null, skipped_count: record.summary?.skipped ?? null, diff --git a/packages/services/service-automation/src/sys-automation-run-failed-count-verdict.test.ts b/packages/services/service-automation/src/sys-automation-run-failed-count-verdict.test.ts new file mode 100644 index 0000000000..752e99135c --- /dev/null +++ b/packages/services/service-automation/src/sys-automation-run-failed-count-verdict.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SysAutomationRun } from './sys-automation-run.object.js'; + +/** + * `sys_automation_run` — the DELIBERATE "no `failed_count` column" verdict + * (#15606, decision batch #76: option 2, `failed` stays in the blob). + * + * `FlowRunSummary` carries five run-level totals. Four of them + * (`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count`) have a + * column on this object; `failed` does not. That asymmetry was filed as a + * finding and ruled on rather than fixed: 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 an operand of that + * expression; it would be its own predicate (`failed_count > 0`), nobody alerts + * on it today, and a caller that wants it has already fetched `summary_json`. + * + * This file is what stops the verdict from being an absence nobody can see. The + * reasoning itself lives where the next reader meets it — in the comment above + * `selected_count`, the same paragraph that provokes the question — and this + * pin holds the SCHEMA half of it honest: + * + * - the asymmetry it explains is still real (four columns, no fifth), so the + * prose cannot outlive its subject; + * - `summary_json`'s description still NAMES `failed` as the place to read + * lost-row counts, so "read it from the blob" does not decay into a blob + * with no documented way in. + * + * ⛔ Do not "fix" a failure here by deleting the assertion. Two legitimate ways + * to turn this file red, and both are edits to the verdict, not to the pin: + * add `failed_count` (the re-open condition — a real need to ALERT on "which + * runs lost rows", null on old rows and never `0`, mirroring `unmeasured_count`; + * one column on an ADR-0103 engine-owned object, human floor), or rewrite the + * `summary_json` description — in which case it still has to name `failed`. + */ +describe('sys_automation_run — `failed` stays in the blob (#15606 verdict)', () => { + const fields = SysAutomationRun.fields as Record>; + + it('carries the four counters that a single filter expression needs in one row', () => { + // Positive control for the absence assertion below: these four read back + // through the SAME accessor path, so `failed_count === undefined` is a + // measurement of the schema and not of a typo'd lookup. + for (const name of ['selected_count', 'acted_count', 'skipped_count', 'unmeasured_count']) { + expect(fields[name], `${name} is expected to be a column`).toBeDefined(); + expect(fields[name].type).toBe('number'); + } + }); + + it('declares no `failed_count` column — the verdict itself', () => { + expect(fields.failed_count).toBeUndefined(); + // Guard the spelling too: a `failed`/`failures` column landing under any + // other name is the same stored-surface change and needs the same ruling. + const match = (names: string[]) => names.filter((name) => /fail/i.test(name)); + // Positive control, so the empty result below is a measurement: the same + // matcher over the same key list plus the name the re-open condition would + // add does fire. + expect(match([...Object.keys(fields), 'failed_count'])).toEqual(['failed_count']); + expect(match(Object.keys(fields))).toEqual([]); + }); + + it('`summary_json` description NAMES `failed` as the place to read lost-row counts', () => { + const description = fields.summary_json?.description; + expect(typeof description).toBe('string'); + // The load-bearing token: the blob is only a usable answer to "which runs + // lost rows?" if the field that answers it is named here by the name a + // caller will find in the parsed JSON. + expect(description as string).toContain('`failed`'); + }); + + it('does not hide the counter from the run row by promoting a phantom column into the highlight set', () => { + // `highlightFields` is the operator-facing surface of this object; if a + // later edit lists `failed_count` there, the column verdict has moved and + // the prose above `selected_count` is stale. + expect(SysAutomationRun.highlightFields).not.toContain('failed_count'); + expect(SysAutomationRun.highlightFields).toContain('acted_count'); + }); +}); diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index bbc7a552d3..2cb00e1a1b 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -330,6 +330,31 @@ export const SysAutomationRun = ObjectSchema.create({ // the failure mode is silent: a detector that fires during normal operation // gets muted, and a muted broken-sweep detector is the same silence #4347 // produced — except it now looks monitored. + // + // [#15606] And `failed` deliberately does NOT get one — the asymmetry + // below is a decision, not an oversight, recorded here because this is the + // paragraph that provokes the question. The four counters exist because + // ONE filter expression needs them in ONE row: `selected_count > 0 AND + // acted_count = 0`, qualified by `unmeasured_count`. A WHERE clause cannot + // reach into a JSON blob for an operand, so every operand of that one + // expression has to be a column or the expression cannot be written at + // all. `failed` is not one of its operands. It would be its OWN predicate + // (`failed_count > 0`), nobody alerts on it today, and a caller that wants + // it has already fetched `summary_json` — where the fold + // `failed = Σ nodes[].failures` sits, and which says so in its own + // description (pinned by `sys-automation-run-failed-count-verdict.test.ts`, + // so this explanation cannot rot into a lie about the schema). + // + // Re-open condition, stated so it is not a matter of taste later: 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`, because "not tracked" + // and "nothing failed" are different answers and only one of them should + // stay quiet. One column on an ADR-0103 engine-owned object: a human-floor + // change, never a rider. Until such a need is named, "readable but not + // queryable" is the right trade for this one counter — which is the same + // sentence as the first paragraph above, reaching the opposite conclusion + // because the input differs: there, an alarm existed to serve. selected_count: Field.number({ label: 'Records Selected', required: false, @@ -361,7 +386,7 @@ export const SysAutomationRun = ObjectSchema.create({ summary_json: Field.textarea({ label: 'Run Summary', required: false, - 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.', + 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`).', group: 'Outcome', }), From fe139b77efd7a1e395b708c13536640b1c0bd2be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 04:35:25 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../automation-run-failed-stays-in-the-blob.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.changeset/automation-run-failed-stays-in-the-blob.md b/.changeset/automation-run-failed-stays-in-the-blob.md index e8f07da357..29ec86c970 100644 --- a/.changeset/automation-run-failed-stays-in-the-blob.md +++ b/.changeset/automation-run-failed-stays-in-the-blob.md @@ -25,10 +25,18 @@ What a consumer sees change: - `summary_json`'s `description` now names `failed` as the field to read lost-row counts from, states that the run-level totals live in the blob alongside the per-node breakdown, and repeats the `unmeasured`/`failed` - convention that an absent count means "not tracked", never zero. This string - ships in the published bundle and is what a Studio/admin surface renders for - the field, which is why this carries a changeset rather than - `skip-changeset`. + convention that an absent count means "not tracked", never zero. ⚠️ This is + why the change carries a changeset and NOT `skip-changeset`, and it was + MEASURED rather than assumed from "it's only prose": `SysAutomationRun` is + re-exported from `src/index.ts`, `package.json` publishes `files: ["dist"]`, + and after `pnpm --filter @objectstack/service-automation build` the new + description text is present in BOTH published entry points — one hit each in + `dist/index.js` and `dist/index.cjs`. `skip-changeset` is for a diff that + publishes nothing from any released package; this one changes bytes inside a + released package's shipped bundle, so it does not qualify. (`description` is + also what the authorable `help` / `helpText` keys alias onto in + `packages/spec/src/data/object.zod.ts` — documentation a consumer surface can + render, not an internal note.) - The comment above `selected_count` — the paragraph that explains why the four are columns, and therefore the paragraph a reader is in when they notice the fifth is not — now carries the verdict for `failed` and the one