Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/automation-run-failed-stays-in-the-blob.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
"@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 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
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>;

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
}),

Expand Down
Loading