fix(service-automation): a notify node reports the recipients it addressed, so a zero-delivery run stops reading like a successful one - #17339
Conversation
A notify node whose delivery came back zero contributed `acted: 0` and nothing else, so a run that notified nobody folded to `selected: 0, acted: 0, unmeasured: 0` -- the same triple a run with no notify node at all reports, and one the broken-sweep filter cannot match because its first clause is `selected > 0`. Report `selected` (the recipient entries addressed) on every path that reaches a recipient list. `acted` / `unmeasuredEffect` keep their rules, so a delivering run's reading is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Same flow, same recipients, both trigger families (the cron tick context ScheduleTrigger really builds, and the REST trigger's session context), on both drivers (memory and better-sqlite3), over the real MessagingService + outbox + inbox channel. Three-way comparison: the zero-delivery run, the delivering run, and a run that genuinely had nothing to notify about -- the first two of which used to be told apart only by a token on the OTHER row, and the first and third of which were the same triple. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…emory driver `pnpm check:driver-memory-census` refuses a new binding to @objectstack/driver-memory and says in as many words that adding a ledger entry to silence it is not the author's call: the consumer set is a maintainer ruling. Take the migrate route instead -- the matrix is now sqlite-wasm x sqlite-native, two real storage implementations. Admitting a memory arm needs that ruling; noted on the PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ly widening check:type-source-resolution refuses @objectstack/driver-sqlite-wasm and @objectstack/platform-objects as new dist-resolved type imports, says widening its shrink-only registry is not the fix, and names `paths` as the measured-wrong tool for a package whose rootDir is `src` (TS6059). Its own remedy for that case is to not take the dependency. So: object fixtures are declared locally, and the non-SQL arm is an in-process IDataEngine rather than the frozen mingo driver. Declared as a deviation in the file header; the real memory arm needs a ruling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ro-delivery-distinguishable
Two devDependencies were added and then taken back out (the frozen memory driver, then the wasm one); the manifest returned to its original state but the lockfile kept the entry. Restored to origin/main and re-verified with a full `pnpm install`, which rewrote nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The three rows the PR publishes are now assertions rather than prose, so the table is a measurement anyone can re-run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift Check1 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 4a937f906db5e201a2f12b1c4db2997e32e90a7f && git checkout 4a937f906db5e201a2f12b1c4db2997e32e90a7f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d29fdcfa97bdcef52e8d8312adb4d07870d46d06 eca44d361489196ca1bf9cefc24123019245bd3f && git checkout -B drift-repro d29fdcfa97bdcef52e8d8312adb4d07870d46d06 && git merge --no-ff eca44d361489196ca1bf9cefc24123019245bd3f
node scripts/docs-audit/affected-docs.mjs --json d29fdcfa97bdcef52e8d8312adb4d07870d46d06 |
…e through the producer predicate The zero-delivery differential control's second arm is an in-process IDataEngine standing in for the frozen mingo driver. Its findOne read an absent filter as "match everything", which is looser than ObjectQL.findOne — the exact shape check:engine-double-contract pins, and the shape that turns a green suite into no suite at all. Open it with assertEngineFindOnePredicate(object, query), imported from @objectstack/metadata-core — the predicate's home, and already a declared dependency of this package, so no new dependency edge and no objectql reverse edge. The RETAINED ledger learns the new row through the gate's own --write; the shrink-only baseline is untouched, byte for byte. All nine cases of the differential control still pass unchanged: no assertion was loosened to accommodate the predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…inators this double does not implement
Two further gates were RED on this same in-process engine double before this
branch's last head, and neither had run in CI: the lint job executes its gates
sequentially under `bash -e`, so check:engine-double-contract's exit 1 halted
the job and masked every step behind it. Both are the same defect class as the
finding that halted it — a test double looser than the engine it stands in for
— in the same literal, and both gates state their baseline never grows, so the
mechanical fix each prints is the only route.
check:where-matcher — `matches` read a combinator as a FIELD NAME. No row
carries a column called `$or`, so such a clause silently drops every row and
this arm would report "nobody was reached" for a reason that is not the one
under test, in the very file written to make that distinction visible. The
store answers scalar equality, so it now refuses a combinator loudly rather
than answering wrongly.
check:objectql-double-limit — `find` applied the caller's bound by truthiness,
so `limit: 0` returned every row: the one call that asked for none. Applied by
presence now, after the filter.
Neither baseline gained a file ("no files added", both gates). No assertion and
no case was touched: the diff contains zero `expect(`/`it(`/`describe(` lines,
and all nine cases of the differential control still pass on both arms,
CONTROLs included — the in-process arm still genuinely delivers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Patch round on top of
|
| gate | at 44a30c57d |
at eca44d361 |
|---|---|---|
check:engine-double-contract |
exit 1 — PINNED [findOne], line 235 |
exit 0 — OK — 852 pinned, 133 in the DEBT ledger, 3 exempt |
check:where-matcher |
exit 1 — line 232 matches: no combinator branch |
exit 0 — 398 matcher(s) discovered, 398 answer the combinator battery correctly or refuse it loudly (261 refuse); no files added |
check:objectql-double-limit |
exit 1 — line 241: truthiness, not presence | exit 0 — none new; no files added |
1 · findOne (the named finding). The in-process IDataEngine arm read an absent filter as "match everything", which ObjectQL.findOne refuses (#4419). It now opens with assertEngineFindOnePredicate(object, query) from @objectstack/metadata-core — the predicate's home since #5619, and already a declared dependency of this package, so no new dependency edge and no objectql reverse edge. The RETAINED ledger learned its row through the gate's own --write (+1 row, 0 lost). scripts/engine-double-contract.baseline.json is untouched byte for byte: f4bfac2a3ee8c3ad31c5204885c9f9503ac3d095 before and after.
2 · matches read a combinator as a FIELD NAME. No row carries a column called $or, so such a clause silently drops every row and this arm would report "nobody was reached" for a reason that is not the one under test — in the very file written to make that distinction visible. The store answers scalar equality, so it now refuses a combinator loudly instead of answering wrongly (the route the gate sanctions; 261 of 398 matchers in the repo conform that way).
3 · find applied the caller's bound by truthiness, so limit: 0 returned every row: the one call that asked for none. Applied by presence now, after the filter.
Nothing was loosened to accommodate any of it. This round's diff contains zero expect( / it( / describe( lines, and all nine cases of the differential control still pass on both arms with their CONTROLs — the in-process arm still genuinely delivers rather than passing quietly:
✓ [driver=in-process] DIFFERENTIAL CONTROL · zero-delivery row · notify node row · CONTROL: cron tick TODAY still delivers
✓ [driver=sqlite] DIFFERENTIAL CONTROL · zero-delivery row · notify node row · CONTROL: cron tick TODAY still delivers
✓ apiTriggerMatchesProductionBuilder
Test Files 1 passed (1) · Tests 9 passed (9)
At eca44d361: pnpm --filter @objectstack/service-automation typecheck && … test → check:test-typecheck: OK, 129 passed (129) files, 1522 passed (1522) tests. The derived gate union for this change set (scripts/pm/dispatch-gates.mjs, 96 families) is green except check:dual-build-cjs-loads and check:type-check-debt, which each exit 3 = PREREQUISITE NOT MET = NOT MEASURED locally (both need a whole-repo build) — recorded as unmeasured, never as a pass.
Generated by Claude Code
Fixes #17337
Clause-②: no — no new exported symbol, nopackages/spec/**edit, noerror-code-ledgerrow. Four files: a changeset,content/docs/automation/flows.mdx,notify-node.ts(+51/−3) and a new integration test.⭐ The finding that shaped the fix: the obvious repair would have made it worse
The card asked for a zero-delivery run to become distinguishable, and the obvious place to put that is
unmeasured. Measuring its contract first says otherwise.ExecutionStepMetrics.unmeasuredEffect, verbatim (packages/spec/src/automation/execution.zod.ts):and the same docblock documents the broken-sweep first filter —
selected > 0 AND acted = 0 AND unmeasured = 0— noting that an understated0puts a run that DID act inside it while an overstated1keeps a run that acted on nothing outside it, "which is the original bug back again."⇒ Writing a measured zero into
unmeasuredwould have taken the zero-delivery run out of the very filter it belongs in. ⛔ A new lie in place of the old silence.The actual fix
The notify node had been reporting only half of a pair. It now reports
selected— the recipient entries it addressed — on every path that reaches a recipient list, with theacted/unmeasuredEffectrules byte-unchanged.A zero-delivery run therefore reads
selected=N acted=0, with nounmeasured, and lands inside the broken-sweep filter — whose first clause it could never satisfy while the node reported noselectedat all.⭐ This closes the class, not one cause:
emit()returningdelivered:0/enqueued:0from an empty audience, an all-suppressing preference filter, a dedup hit, an all-failing enqueue, and a stack with no messaging service all land in the same place.The card's fence, discharged as a measurement rather than a promise
The card forbids closing this because #16659 landed. The differential control therefore drives the schedule arm in both shapes — today's org-less cron tick and the org-carrying one #16659 gives it — and the zero-delivery arm is the post-#16659 one. ⇒ "#16659 is unrelated" is a reading here, not an assurance.
Evidence (measured at
44a30c57d, pre-suspension)Step 1 — the contract, before any code.
unmeasuredhas exactly one writer (run-summary.ts'ssummarizeRun, fed byExecutionStepMetrics.unmeasuredEffectfrom six executors plus the child-run rollup). Its readers: the[automation] runline (which prints the token only when non-zero),sys_automation_run.unmeasured_count+summary_json, the REST run summary, the map/subflow parent rollups, and one cross-package pin inqa/dogfood. The CLI does not render the counters.Suites — service-automation 129 files / 1522 tests + typecheck green; spec 470 / 13211; runtime (the 7 files of the run-summary population) 362 tests.⚠️ The first runtime attempt read
173 failed FILES / 1 failed testand was discarded as NOT MEASURED: every one wasFailed to resolve entry for package @objectstack/service-datasource— an unbuilt closure, which reads exactly like a diff that broke every import.qa/dogfood'sflow-function-effect.dogfood.test.ts: 2 tests green against a freshly builtservice-automationdist — the positive control of the cross-package sweep, since that pin resolves this package throughdist/.Differential control —
notify-zero-delivery-visibility.integration.test.ts, 9 tests, 2 trigger families × 2 data layers, with a realMessagingService, a realRecipientResolver, a real outbox and a real inbox channel. ⛔ Neither family is hand-rolled: the schedule arm gets the literal context production'sScheduleTriggerbuilds; the API arm the identity shapebuildAutomationContextcopies off a session (pinned against drift in-file). The three published summary lines are asserted verbatim, so the table below is re-runnable rather than recalled.Ablation — prediction written first (turns RED, 8 of 9; the ninth is the context-shape pin that does not touch the node), one shell under
trap restore EXIT INT TERM, mutation observed on disk by anchor count (selected: recipients.length3 → 0), mutated blob0ea7f5e9≠ HEAD blob2afe90db, with a loud refusal wired for an empty-or-equal hash. Measured:Tests 8 failed | 1 passed (9)— failing onexpected 0 to be greater than 0and onexpected 'selected=0 acted=0 unmeasured=0' not to be 'selected=0 acted=0 unmeasured=0', i.e. rows 1 and 3 becoming the same value again — the finding reproduced. Restored withgit checkout HEAD -- <abs>and proven by state: emptygit diff HEAD, restored blob == HEAD blob, cleangit status --porcelain. ⛔ Never by exit code.Gates — re-derived at the final head (the first derivation warned STALE TREE, so
origin/mainwas merged and it was derived again — identical family set);--ranreconciles 88 derived, 88 run, 0 UNRUN. ⛔ Its0 NOT-MEASUREDis the runner's own claim and is wrong; per-command exits were captured, so annotated by hand:check:dual-build-cjs-loadsexit 3 andcheck:type-check-debtexit 3 arePREREQUISITE NOT MET= NOT MEASURED, never a pass;check:skill-examplesexit 1 is the same thing spelled with the wrong code (it declines becauseclient-react/distholds no declarations and says a verdict now would be a false green) = NOT MEASURED, not a red about this diff. Three gates were genuinely this diff and are now 0:check:driver-memory-census,check:test-source-alias,check:type-source-resolution. Other 82 green.Carriers —
check-clause2-carriers.mjson the original PR: exit 0, "readable in the fixed spelling and both carriers agree".check-partof-closing-keywordwith the realPR_BODY+PR_COMMITS_FILE: exit 0, "7 commit message(s) carry no card-relation trailer".The card requires the differential control on both drivers, memory and sqlite. The sqlite arm is real (ObjectQL + driver-sql on better-sqlite3). The memory arm is not the mingo
InMemoryDriver— it is an in-processIDataEngine, declared as a deviation in the test header.Two gates each refused a route and each says the author must not decide it:
check:driver-memory-censusrefused binding a third consumer, stating that "is this consumer legitimate?" is a maintainer ruling (#5704 Q2, #6664 A) and the gate is bookkeeping under it, never a second opinion; andcheck:type-source-resolutionrefused widening this package's shrink-only type-source registry, callingpathsmeasured-wrong here (the TS6059 shape, PR #12570).⇒ Neither gate was routed around. The thing actually under test — notify metrics →
summarizeRun→ the folded payload — never touches a driver, so the storage axis is a robustness check; the reading itself is the trigger-family axis, and that is fully driven on both arms. Ruling a third consumer into the census (option A) is a small maintainer decision that would restore the card's exact wording, if wanted.Generated by Claude Code