test(service-automation): characterize a config-less wait / boundary_event node at run time - #17937
Conversation
…ry_event` node at run time
Converts a source reading into an observed run. `@objectstack/spec` accepts a
`wait` node whose `waitEventConfig` block is absent entirely — the state a
freshly created node is in — while refusing one whose block is present with
`eventType` omitted. What the executor then does with the accepted document had
never been measured.
Recorded, not endorsed:
- `wait`, no config block: returns `{ success: true, suspend: true,
correlation: 'timer:<nodeId>', output: undefined }` — the `'timer'` default
applies, the timer branch computes no deadline, so no wake-up job is armed
(with a job service present), no `waitUntil` is persisted, and not one log
line is emitted at any level. The run parks until an external `resume()`.
- CONTROL, the same node with `eventType: 'timer'` + `timerDuration: 'PT1H'`:
one `once` job armed, `waitUntil` written, correlation carrying the job's
name — every channel that read zero reads non-zero, so the zeros are a
reading of the absent-config path and not of an inert harness.
- `boundary_event`, no config block: a different execution branch entirely, so
measured separately rather than extrapolated. No executor is registered for
the type, so the run fails loudly — `success: false`, `status: 'failed'`,
`No executor registered for node type 'boundary_event'` — and the sealed
vocabulary names it before any run.
- CONTROL, a fully populated `boundaryConfig`: fails identically, proving the
block is never consulted; and the same flow with a registered type in that
slot runs to completion, proving the fixture is sound.
Executor behaviour is deliberately unchanged: the `?? 'timer'` fallback carries
a comment declaring it intentional, and whether a duration-less timer wait
should warn or refuse is a product question for a successor card.
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Contract reviewHead reviewed: Implemented-by: ① Clause-② — re-derived from the DELIVERED diff, ⛔ not inherited from the claim-time prediction and ⛔ not accepted from the reportMeasured in-seat at the reviewed head:
⇒ Clause-②: ② Scope fence — heldThe dispatch fenced this card to measure, ⛔ do not repair. Verified: the diff contains no non-test file, so no executor behaviour moved. The ⭐ And the fence earned its keep. The dispatch forbade extrapolating ③ Measurement discipline — the controls discriminate⛔ A zero whose control also reads zero is not evidence. Every channel that reads zero on the absent-config ⭐ The delivery also corrected the source reading it was sent to confirm: Gates53 families derived and reconciled with Verdict: PASS
Generated by Claude Code |
Fixes #17843
Clause-②: no
Refs objectstack-ai/objectui#9109 (that card's grade depends on the reading below; nothing over there gates this one).
What this is
A measurement, converted from a source reading into an observed run. Executor behaviour is
deliberately unchanged — the card says so itself, and the
?? 'timer'fallback inwait-node.tscarries a comment declaring it intentional.The triage seat answered the card from source (comment 5651444086). This PR runs it. The run
agrees with that reading on the
waithalf, so the p1 escalation on objectui#9109 stands andneeds no rollback. The
boundary_eventhalf — which triage explicitly did not measure — comesout the opposite shape, which is exactly why the card refused to extrapolate.
The four readings, as recorded
All four come from real
engine.execute()runs throughAutomationEngine, with the node-typevocabulary sealed (what
AutomationServicePlugindoes atkernel:bootstrapped), a job servicepresent, an
InMemorySuspendedRunStoreattached, and one recording logger behind both the engineand the plugin context so "any log line at any level" is answerable.
1.
wait, no config block at all⇒ the
'timer'default applies, the timer branch computes no deadline, no wake-up job is armedeven though a job service was available, no
waitUntilis persisted, and not one log lineis emitted at any level. The run parks until something external calls
resume(runId).outputis apresent key carrying
undefined, not an absent key. A JSON dump of the return value drops itand reads as the second. The test pins it with
toStrictEqualfor that reason —toEqualcannottell those two apart.
2. CONTROL — the same node WITH
eventType: 'timer'andtimerDuration: 'PT1H'⇒ every channel that read zero above reads non-zero here, so the zeros are a reading of the
absent-config path and not of an inert harness. The one channel that does not discriminate is
status: 'paused'— both halves park the run — and the test records that too, so a later readerdoes not mistake it for a signal.
3.
boundary_event, no config block at all — measured separately, NOT extrapolated⇒ ⭐ the opposite of the
waithalf: loud, and loud twice. The type has no executor at all(
installBuiltinNodesseeds twelve packs, none of them this one; the package README filesboundary_eventas the BPMN interop representation rather than the native authoring model), sothe dispatch fails before any config block could be read. There is no silent suspension on this
branch to find.
4. CONTROL —
boundary_eventwith a fully populatedboundaryConfigFails identically (
success: false, sameerrorstring, same step failure) ⇒ the block isnever consulted. A third control puts a registered type in the same slot in the same flow and
the run completes (
ran: [ 'before', 'b', 'after' ]), so the failure above is the node type, notthe fixture.
Premise check
The card's premise holds on the tip, verified rather than assumed, and is pinned in the test:
FlowNodeSchemaaccepts awaitnode with nowaitEventConfigat all, refuses one whoseblock is present with
eventTypeomitted, and accepts aboundary_eventnode with noboundaryConfig. So the documents measured above are ones an author can really save.The durable form
packages/services/service-automation/src/builtin/absent-config-node-characterization.test.ts—named, titled and headed as a characterization, with the header stating in as many words that
every assertion is a photograph and not an endorsement, that a red here means "the recorded
behaviour moved" (possibly on purpose), and that whether a duration-less timer wait should warn or
refuse is a product question for a successor card rather than anything this file blesses.
Verification
pnpm --filter @objectstack/service-automation test— 133 files, 1571 tests, all pass (thenew file is 7 of them).
pnpm --filter @objectstack/service-automation typecheck— exit 0.pnpm --filter @objectstack/service-automation build— exit 0; dependency closure built firstvia
pnpm --filter '@objectstack/service-automation^...' build.scripts/pm/dispatch-gates.mjsand reconciled with--ran: 53 derived, 51 run green, 0 unrun, 2 NOT MEASURED —check:dual-build-cjs-loadsandcheck:type-check-debt, both exit 3(
PREREQUISITE NOT MET: each reads built output for the whole workspace, whichlint.ymlbuildsbefore invoking them). Neither is a finding, and this diff emits nothing into any
dist/forthem to read.
pnpm lint(the repo-wideeslint . --no-inline-config) — exit 0, whole population, nonarrowing claimed.
pnpm check:nul-bytesgreen, plus a direct control-character scan of the added file(
grep -naPover the C0 set plus DEL) — no matches.Gate log and reconciliation taken at
d5e7d32c3, the final commit.Changeset
skip-changeset, measured rather than assumed: the package publishesfiles: ["dist", "README.md", "CHANGELOG.md"], and after a real build a probe symbol unique to theadded file appears in 0 of those paths, while the positive control
rearmSuspendedWaitTimersappears in 2 files under
dist/. Nothing already published moves.Clause-② re-derived from the delivered diff:
no. The diff is exactly one added file, a*.test.tsundersrc/, outsidefiles[]and unreachable from the published entry(
exports: { "." }resolves intodist/). It adds no exported symbol reachable from a publishedentry and no key on an already-published payload — the file declares no exports at all, and in any
case the grading test is reachability, not the appearance of the word export in a diff.
Acceptance notes
Out of scope for this card, noted rather than filed:
The card states it is not a request to change anything, and the dispatching seat reserved the
successor question — should a duration-less timer wait warn or refuse? — for itself.
outputbeing a present-but-undefinedkey rather than an absent one is invisible to a JSONrendering of the return value. An observation about how the value reads, not a defect.
completedorfailedrun gets an[automation] run flow=... status=...line. Consistent with "paused is not terminal"; recordedbecause it is the other half of why the absent-config wait is silent end to end.
boundary_eventparses clean and then fails the run withNO_EXECUTOR. Not filed: the enginetreats "a node type nothing registered" as a declared, handled state with a startup warning that
names the type, and the package README declares this type interop-only. Loud by design, not a
trap.
Generated by Claude Code