docs(automation): record why failed has no column on sys_automation_run, and name it in summary_json - #16759
Conversation
…n_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 Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ff6e3700212c11787df288558e498559e85709d1 && git checkout ff6e3700212c11787df288558e498559e85709d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8b37a0973d20b417569f934ad5545e2a39dca257 eab69961da0f02598e111074985d74ae6b6f460a && git checkout -B drift-repro 8b37a0973d20b417569f934ad5545e2a39dca257 && git merge --no-ff eab69961da0f02598e111074985d74ae6b6f460a
node scripts/docs-audit/affected-docs.mjs --json 8b37a0973d20b417569f934ad5545e2a39dca257 |
…iled-stays-in-the-blob
…hips 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
…iled-stays-in-the-blob
Fixes #15606
Option 2 of the card, per the ruling on comment 5567696655 (director seat, decision batch #76, 2026-09-07; maintainer verbatim 「同意」): no
failed_countcolumn now.FlowRunSummary.failedstays inside thesummary_jsonblob onsys_automation_run, and the schema now says why.⛔ This is not "do nothing". The ruling's test of done is its own sentence — "the asymmetry is explained where the next reader will look, so the card is not re-filed" — so the deliverable is judged by placement, not by word count.
Where the explanation went, and why that is where the next reader looks
packages/services/service-automation/src/sys-automation-run.object.ts, appended to the comment block that already sits directly aboveselected_count.That block is not a neighbouring location — it is the cause of the card. Its first paragraph is the "COLUMNS, not just a blob … the difference between a dashboard and an alarm" argument, which triage measured at
:312withselected_countat:333andacted_countat:340. A reader arrives at that paragraph, learns that four totals are columns because an operator can only alert on what is filterable, and then reads the four field declarations that follow it. Noticing that a fifth total exists and has no declaration among them is a consequence of having just read that paragraph, not an independent act. The answer is now in the same block, before the four declarations, so the question and its answer cannot be reached separately.Concretely, the new prose states the ruling's own criterion: the four are columns because ONE filter expression needs them in ONE row (
selected_count > 0 AND acted_count = 0, qualified byunmeasured_count), aWHEREclause cannot reach into a JSON blob for an operand, andfailedis not one of that expression's operands — it would be its own predicate, nobody alerts on it today, and a caller that wants it has already fetchedsummary_json.Two supporting placements, because "where the reader will look" is not one address:
summary_json's owndescriptionnow namesfailedas the field to read lost-row counts from, states that the run-level totals live in the blob beside the per-node breakdown, and repeats the "absent is not zero" convention. This is the deliverable's second half: the prose above tells a reader the count is in the blob, and this is what makes the blob a usable answer instead of a redirection.ObjectStoreSuspendedRunStore's terminal-row write (suspended-run-store.ts), where fourrecord.summary?.X ?? nulllines sit and a fifth would be added, now carries a five-line signpost to that verdict.One placement deliberately NOT made
packages/spec/src/automation/execution.zod.ts, whereFlowRunSummary.failedis declared, was considered and rejected — recorded here so the omission reads as a decision rather than an oversight. Two reasons, and the first is the load-bearing one: the asymmetry is not visible there. That file declaresfailedandunmeasuredas peers, both optional, with no columns anywhere in sight; nothing in it can prompt "why does this one lack a column?", because it has no columns to be asymmetric with. The question only exists where the four declarations are. Second, it ispackages/spec— a heavier surface carrying generated artifacts — and the ruling scoped the dispatch to "prose in one object file plus a pin".The pin
New file
packages/services/service-automation/src/sys-automation-run-failed-count-verdict.test.ts. Four cases:failed_countcolumn, and nofail-named column under any other spelling. The name matcher carries its own positive control inside the test: the same matcher, over the same key list plusfailed_count, fires;summary_json's description still contains`failed`— the pin the card asked for, so the explanation cannot rot into a claim the schema no longer supports;failed_countis not inhighlightFieldswhileacted_countstill is.Ablation — both directions, and one void reading reported
Ablation ran against object-file blob
2cb00e1a1b4dda0274f580ef80325729ff930c11, which is byte-identical to that file at this PR's head, so the reading below is about the tree being reviewed.Test Files 1 passed (1)/Tests 4 passed (4).failed_countfield ⇒Tests 1 failed | 3 passed (4), the failure being exactly the case named declares nofailed_countcolumn — the verdict itself, withAssertionError: expected { …(12) } to be undefined.summary_json's description ⇒Tests 1 failed | 3 passed (4), the failure being exactly the case namedsummary_jsondescription NAMESfailedas the place to read lost-row counts, withAssertionError: expected 'ABLATION: description with no counter…' to contain 'failed'.Each leg reddens exactly one assertion and leaves the other three green, so the pin is targeted rather than a blunt instrument. Mutations were proven on disk by grep counts before and after plus a
git hash-objectdiffering from theHEADblob; each restore is verified by on-disk hash equal to theHEADblob andgit diff HEADempty, through anEXIT INT TERMtrap using absolute paths.No test files found, exiting with code 1. Nothing was measured, and both readings were discarded rather than reported. The numbers above are from the corrected re-run, which is why the control leg and the per-assertion names are quoted: an exit code alone could not have told the two apart.Both legs ran with no rebuild, which is sound here and was checked rather than assumed: the pin reaches its subject through the relative import
./sys-automation-run.object.js, and this package's only vitest alias targets@objectstack/platform-objects, so resolution lands onsrc, neverdist. Nodistpreflight applies.The re-open condition, as recorded
Written into the same comment block, in the ruling's terms and marked as deliberate ("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, mirroringunmeasured_count— null on rows written before the column existed, ⛔ never0, 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. It is also restated in the changeset and in the pin's docblock, which names it as one of exactly two legitimate ways to turn the file red.Clause-② — re-derived,
noThe dispatch's read was provisional and told me to re-derive it from the real export surface, and specifically to measure whether the description strings ship. They do:
@objectstack/service-automationis published (publishConfig.access: public,files: ["dist"]) andSysAutomationRunis exported fromsrc/index.ts.dist/: the new description text is present in bothdist/index.jsanddist/index.cjs(1 hit each). Absent fromdist/index.d.ts(0), as expected for a runtime value. tsup does not minify, so the new source comments ship as well — verified with a single-line comment phrase confirmed single-line in the source first, plus agrep -Pzomultiline cross-check for a phrase that wraps.Shipping in the artifact is not the same as being a contract, and that is where the
nocomes from:origin/mainand the same 28 here,diffclean — same types, samerequiredflags; every accepted set is byte-for-byte what it was.descriptionvalue, plus source comments. Nothing parses it:git grep -lfor the description text resolves to exactly one tracked file, the object file itself. Firing positive control for that same matcher:git grep -l 'unmeasured_count'returns 6 files, so the single-file result is a measurement and not a broken search. It is in no generated baseline or snapshot either.dist, not published.node scripts/pm/check-widening-tells.mjs --declaration no --diff PR-DIFF-FILE⇒ exit 0, "4 changed file(s) read, no widening tell on any declared surface".⇒
Clause-②: no. Noneeds:contract-reviewlabel, no--pair. Not a governed surface either: today's register printsdocs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md, and none of the four paths hits one.Changeset —
patch, ⛔ notskip-changesetAGENTS.md:1029scopesskip-changesetto "a diff that publishes nothing from any released package". Measured above: this diff changes bytes inside the published bundle of a released package. So the label is refused and.changeset/automation-run-failed-stays-in-the-blob.mddeclarespatch, and it now records how that was measured rather than asserting it.descriptionis additionally what the authorablehelp/helpTextkeys alias onto inpackages/spec/src/data/object.zod.ts, so it is documentation a consumer surface can render, not an internal note. Not breaking — nothing authorable is removed or renamed — so no ADR-0087 marker applies, andcheck-adr-0087-registration.mjs --base origin/mainis green.Verification
origin/mainmoved twice during this work anddispatch-gatesreported STALE TREE each time; it was fetched and merged on both occasions and the gate list re-derived before anything was run. Everything below is at headeab69961d, whose merge base withorigin/mainis current.pnpm --filter '@objectstack/service-automation^...' build⇒ exit 0 (dependency closure first, so a staledistcannot fake either direction), thenpnpm --filter @objectstack/service-automation build⇒ exit 0.pnpm --filter @objectstack/service-automation test⇒ 126 files, 1490 tests, all passed.pnpm --filter @objectstack/service-automation typecheck⇒ exit 0 (tsc --noEmitpluscheck:test-typecheck, the latter reporting the test layer compiles with 0 files / 0 errors in the debt ledger). The new pin is genuinely inside that program, not merely adjacent to it:tsc -p tsconfig.test.json --listFilesnames it (1 hit).node scripts/pm/dispatch-gates.mjs --tier⇒ no path-derived model mandate.--commands⇒ 56 families. All 56 ran, all exit 0, and--ranreconciled: "56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED".origin/mainmerge. Because two gate implementations arrived in that merge, the sweep was restarted at this head; 52 of 56 re-ran green here, 0 non-zero throughout. The 4 not yet re-run at this exact head arecheck:type-check-debt,check:type-source-resolution,check:watch-hint-literalandcheck:where-matcher— all green in the full sweep, all unchanged implementations, and none of them reads a file this merge touched. Declared as a narrowing, not claimed as measured; CI runs the full farm.scripts/engine-double-contract.pinned.jsonbackscheck:engine-double-contract, which did re-run green at this head, andscripts/check-partof-closing-keyword.mjsis not in the runnable 56 at all (it sits in the self-test/roster bucket). The re-run also re-verified the ESLint ratchet's baseline against the new merge base8b37a09(the first sweep had verified against73053ed) — green, "none new".cmd > log 2>&1; EXIT=$?), never through| tailor| head.check:type-check-debtre-measured 5 ledger entries (55 raw tsc errors, none above its recorded number),check:dual-build-cjs-loadsloaded 104 published require entry points across 67 packages,check:dts-closureswept 164/164 declaration files across 72 packages.grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over all four changed files finds none.Repo-wide
eslintand the always-runs CI tail are left to CI, which runs the full farm.验收备注
FlowRunSummary.failedfold, loop iteration throughtry_catch→runRegion,$error.iteration/$error.item,failed=on the summary line (engine half of #13681) #14456's fold / summary-line token /$errorrow binding, [finding] A dyingloopdiscards its body's completed steps wholesale — the run summary reportsacted: 0while 5 writes actually happened (childSteps splice only after a SUCCESSFUL node result) #13803, or [finding] service-automation: the durable run-history row foldscancelled(andtimed_out) intofailed— after a restart the Runs surface cannot tell a cancelled run from a failed one #15223 — that last one is a differentfailed(onstatus, not this counter), it is not addressed here, and it stays open. The new comment and the pin's docblock both talk only about the counter, so neither adds fuel to the confusion the card warned about.pnpm check:type-check-debtpins its own CI-shaped ceiling (--max-old-space-size=6144, stated in its output and owned byscripts/check-type-check-coverage.mjs). It passes cleanly when invoked plainly, and only OOMs if a caller exports a smallerNODE_OPTIONSheap into its environment — which the agent guidance's blanket4096for heavy commands will do. An interaction between two documented numbers, not a defect in either; recorded in case they are ever meant to agree.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37