Skip to content

test(service-automation): characterize a config-less wait / boundary_event node at run time - #17937

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-17843-wait-node-absent-config-measurement
Sep 13, 2026
Merged

test(service-automation): characterize a config-less wait / boundary_event node at run time#17937
os-project-manager merged 1 commit into
mainfrom
claude/issue-17843-wait-node-absent-config-measurement

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

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 in
wait-node.ts carries 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 wait half, so the p1 escalation on objectui#9109 stands and
needs no rollback. The boundary_event half — which triage explicitly did not measure — comes
out 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 through AutomationEngine, with the node-type
vocabulary sealed (what AutomationServicePlugin does at kernel:bootstrapped), a job service
present, an InMemorySuspendedRunStore attached, and one recording logger behind both the engine
and the plugin context so "any log line at any level" is answerable.

1. wait, no config block at all

node:                { id: 'pause', type: 'wait', label: 'Wait' }
executor return:     { success: true, suspend: true, correlation: 'timer:pause', output: undefined }
engine result:       { success: true, status: 'paused', runId: 'run_caa5b321-...' }
scheduled jobs:      []
stored variables:    { previous: null, $runId: 'run_caa5b321-...', $flowName: 'f', $flowLabel: 'F' }
                     -- no 'pause.waitUntil' key
log lines during run: []
nodes that ran:      [ 'before' ]        -- the run reached the node and never got past it

⇒ the 'timer' default applies, the timer branch computes no deadline, no wake-up job is armed
even though a job service was available
, no waitUntil is persisted, and not one log line
is emitted at any level
. The run parks until something external calls resume(runId).

⚠️ One correction to the source reading's rendering, found only by running it: output is a
present key carrying undefined, not an absent key. A JSON dump of the return value drops it
and reads as the second. The test pins it with toStrictEqual for that reason — toEqual cannot
tell those two apart.

2. CONTROL — the same node WITH eventType: 'timer' and timerDuration: 'PT1H'

executor return:     { success: true, suspend: true,
                       correlation: 'flow-wait:run_ab5a389c-...:pause',
                       output: { waitUntil: '2026-09-13T07:38:41.591Z' } }
scheduled jobs:      [ { name: 'flow-wait:run_ab5a389c-...:pause',
                         schedule: { type: 'once', at: '2026-09-13T07:38:41.591Z' } } ]
stored variables:    ... 'pause.waitUntil': '2026-09-13T07:38:41.591Z'

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 reader
does not mistake it for a signal.

3. boundary_event, no config block at all — measured separately, NOT extrapolated

registered node types: [ 'mark', 'wait' ]      -- nothing registers 'boundary_event'
engine result:       { success: false, status: 'failed',
                       error: "No executor registered for node type 'boundary_event'" }
summary node 'b':    { nodeType: 'boundary_event', status: 'failure', runs: 1, failures: 1 }
at vocabulary seal:  warn -- "Flow 'f' references node type(s) with no registered executor or
                     descriptor ... these nodes fail at execution time with NO_EXECUTOR."
                     meta: { unknownTypes: [ 'boundary_event' ], knownTypes: [...] }
during the run:      info -- "[automation] run flow=f run=... status=failed ... failed=1"
nodes that ran:      [ 'before' ]

⇒ ⭐ the opposite of the wait half: loud, and loud twice. The type has no executor at all
(installBuiltinNodes seeds twelve packs, none of them this one; the package README files
boundary_event as the BPMN interop representation rather than the native authoring model), so
the dispatch fails before any config block could be read. There is no silent suspension on this
branch to find.

4. CONTROL — boundary_event with a fully populated boundaryConfig

Fails identically (success: false, same error string, same step failure) ⇒ the block is
never 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, not
the fixture.

Premise check

The card's premise holds on the tip, verified rather than assumed, and is pinned in the test:
FlowNodeSchema accepts a wait node with no waitEventConfig at all, refuses one whose
block is present with eventType omitted, and accepts a boundary_event node with no
boundaryConfig. 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 test133 files, 1571 tests, all pass (the
    new file is 7 of them).
  • pnpm --filter @objectstack/service-automation typecheck — exit 0.
  • pnpm --filter @objectstack/service-automation build — exit 0; dependency closure built first
    via pnpm --filter '@objectstack/service-automation^...' build.
  • Gate families derived from the diff with scripts/pm/dispatch-gates.mjs and reconciled with
    --ran: 53 derived, 51 run green, 0 unrun, 2 NOT MEASURED
    check:dual-build-cjs-loads and check:type-check-debt, both exit 3
    (PREREQUISITE NOT MET: each reads built output for the whole workspace, which lint.yml builds
    before invoking them). Neither is a finding, and this diff emits nothing into any dist/ for
    them to read.
  • pnpm lint (the repo-wide eslint . --no-inline-config) — exit 0, whole population, no
    narrowing claimed.
  • pnpm check:nul-bytes green, plus a direct control-character scan of the added file
    (grep -naP over 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 publishes
files: ["dist", "README.md", "CHANGELOG.md"], and after a real build a probe symbol unique to the
added file appears in 0 of those paths, while the positive control rearmSuspendedWaitTimers
appears 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.ts under src/, outside files[] and unreachable from the published entry
(exports: { "." } resolves into dist/). It adds no exported symbol reachable from a published
entry 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 silent permanent suspension itself is confirmed, and is deliberately not repaired here.
    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.
  • output being a present-but-undefined key rather than an absent one is invisible to a JSON
    rendering of the return value. An observation about how the value reads, not a defect.
  • A paused run emits no run-level log line at all, where a completed or failed run gets an
    [automation] run flow=... status=... line. Consistent with "paused is not terminal"; recorded
    because it is the other half of why the absent-config wait is silent end to end.
  • boundary_event parses clean and then fails the run with NO_EXECUTOR. Not filed: the engine
    treats "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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 84e6b05b6d295f1c744d236921300f447cf7791epackageMentionDocs.

@os-project-manager os-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 13, 2026 — with Claude
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Contract review

Head reviewed: d5e7d32c3d7df102819816cd5c4b170a62013e62

Implemented-by: claude/issue-17843-wait-node-absent-config-measurement (mode:subagent — the branch, not a session)
Reviewed-by: session_01URLHobLUJB9K1ABV6ofdjj (domain:services execution seat)

① Clause-② — re-derived from the DELIVERED diff, ⛔ not inherited from the claim-time prediction and ⛔ not accepted from the report

Measured in-seat at the reviewed head:

limb of the mechanical floor reading
diff shape git diff --name-status origin/main...one line, A packages/services/service-automation/src/builtin/absent-config-node-characterization.test.ts
new exported symbol reachable from the published entry the added file declares 0 exports (grep -cE '^\s*export' = 0); src/index.ts references it 0 times (19 ^export lines, none of them this file); package.json publishes files: ["dist","README.md","CHANGELOG.md"] and exports: { "." } resolving to ./dist/index.js / ./dist/index.d.tssrc/** is not published at all
new key on an already-published payload no runtime payload is touched — the diff adds no non-test source
changeset git diff --name-only origin/main... -- .changeset/empty, consistent with the skip-changeset label

Clause-②: no. Both limbs fail. ⭐ The test is reachability from the published entry, ⛔ not the presence of the word export in the diff — recorded because this file would answer no on either reading and that coincidence must not be mistaken for the rule.

② Scope fence — held

The dispatch fenced this card to measure, ⛔ do not repair. Verified: the diff contains no non-test file, so no executor behaviour moved. The 'timer' fallback in wait-node.ts is untouched.

⭐ And the fence earned its keep. The dispatch forbade extrapolating boundary_event from wait; measured separately, it came out the opposite shape — no executor is registered for the type at all, so it fails loudly (NO_EXECUTOR, plus a warn at vocabulary seal naming unknownTypes: ['boundary_event']), and a fully populated boundaryConfig fails identically, proving the block is never consulted. Had the half been generalised, this PR would have reported a silent hang on a branch that is loud by design.

③ Measurement discipline — the controls discriminate

⛔ A zero whose control also reads zero is not evidence. Every channel that reads zero on the absent-config wait reads non-zero on the populated-config control: wake jobs 0 → 1, waitUntil absent → present, correlation inert timer:pause → the job's own name. ⭐ And the one channel that does not discriminate (status: 'paused', true of both halves) is recorded as such, so a later reader cannot misread it as a signal. boundary_event carries its own anti-vacuity control: a registered type in the same slot of the same flow runs to completion.

⭐ The delivery also corrected the source reading it was sent to confirm: output is a present key carrying undefined, not an absent key — a distinction a JSON dump erases and toEqual cannot see, which is why the test pins it with toStrictEqual. The behaviour is unchanged; only its rendering was imprecise. That is the kind of finding only a real run produces, and it is exactly why the card refused to close on source alone.

Gates

53 families derived and reconciled with --ran: 51 green, 0 unrun, 2 NOT MEASUREDcheck:dual-build-cjs-loads and check:type-check-debt, both exit 3 (PREREQUISITE NOT MET), each reading whole-workspace built output that lint.yml builds before invoking them. ✅ Correctly reported as NOT MEASURED, ⛔ not as passes — exit 3 is a statement about the tree, never a verdict.

Verdict: PASS

⚠️ This record binds to the head it names. If d5e7d32c3 moves, this record is re-issued against the new head — re-verifying alone is not sufficient, and the carrier gate reads a clear bound to a stale head as unbacked.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 13, 2026 07:30
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 225197c Sep 13, 2026
40 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-17843-wait-node-absent-config-measurement branch September 13, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants