Skip to content

fix(automation): require the wait / boundary_event config block at the contract, retire the executor's silent timer default - #18175

Merged
os-warren merged 11 commits into
mainfrom
claude/issue-17928-wait-node-config-required
Sep 14, 2026
Merged

os-warren merged 11 commits into
mainfrom
claude/issue-17928-wait-node-config-required

Conversation

@os-warren

@os-warren os-warren commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #17928

A wait flow node with no waitEventConfig block parsed clean and ran to { success: true, suspend: true } — parked forever, reporting success, from the state a freshly created node is in. This PR makes the protocol refuse that document, makes the executor refuse it loudly when a stored one still reaches execute, and settles the two default-free optional keys the same ruling covers.

⚠️ Takeover of an orphaned branch. Five commits were already on claude/issue-17928-wait-node-config-required when this session picked it up; their author's session died before a PR existed. They were diffed and judged, not trusted, and their history was not rewritten — everything since is added on top.

The ruling this implements

Maintainer, decision batch #127 item 5 (C′), recorded on objectui#9354, verbatim and untranslated:

「16678 具体解释,计划用哪个字段判断经理。其他同意」

C′ as presented: the protocol is the source of truth; a designer never invents a default the protocol does not apply; a default the protocol should have is declared by the protocol; a required key has no "unset behaves as".

What lands

A — the contract stops accepting the silent shape (packages/spec)

FlowNodeSchema now requires waitEventConfig on a type: 'wait' node and boundaryConfig on a type: 'boundary_event' node, and the inline waitEventConfig block in flowNodeObject() requires a non-blank timerDuration under eventType: 'timer'. There is no WaitEventConfigSchema export — the block is declared inline, and this body said otherwise until the review round. Each issue is raised under the block's own key, so a flow-level parse reports a top-level node at nodes[i].waitEventConfig with the remedy in the message.

⚠️ Where the refusal bites, stated accurately. A node nested in an ADR-0031 region body is refused at a different door, at a different time. parseFlowNodeRegions parses each region slot with safeParse and, on a refusal, leaves that region raw and continues — a policy that predates this change — so FlowSchema.safeParse of a flow whose loop body holds a block-less wait answers true. What refuses it is the region contract (LoopConfigSchema / ParallelConfigSchema / TryCatchConfigSchema) at body.nodes[i].waitEventConfig, which is the same contract the container node's executor parses its config through at execute time — so the nested shape still cannot run, it is refused one door later and by node id. Both halves are pinned in flow.test.ts ("nested in a region: the flow parse leaves it raw, and the REGION contract refuses it by path"), and the ADR-0087 entry's acceptanceCriteria has said so from the start.

⚠️ The timerDuration requirement is BLOCK-scoped, not node-type-scoped. It sits on the waitEventConfig block, so any node type carrying that key is held to it: FlowNodeSchema.safeParse of a start node spelled waitEventConfig: { eventType: 'timer' } answers false at this head and true on main, with both controls green (the same node with timerDuration: 'PT1H', and a plain start with no block, both true). It only ever narrows, and the block is inert on node types no executor reads it from, so the practical reach is wait — but a stack that spells the key elsewhere must be edited too, which is why the changeset and the migration entry now say "scan for the KEY, not only for the node type".

⛔ The two required enums (wait.eventType, boundary_event.eventType) get no default: a required key has no "unset behaves as", and an indefinite park, if one is ever wanted, is its own declared eventType.

B — the executor follows the contract (packages/services/service-automation)

wait-node.ts carried (node.waitEventConfig ?? {}) and String(wec.eventType ?? 'timer') under a comment declaring the second deliberate — "a wait node without one is a VALID TIMER WAIT". Both fallbacks are retired and the comment is deleted with the reversal named. A node that still reaches execute without the block (a stored pre-migration document on a path that skipped the parse) is now a guard refusal — errorClass: 'guard', so a fault edge cannot route a metadata defect into a handler that reports success — and it logs, naming the node and the remedy, because the defect being closed was silence. Two corrections ride the same return: the timer branch stops answering output as a present key holding undefined, and it is absent when no deadline was computed.

boundary_event gets the contract half only: the runtime registers no executor for that type at all (NO_EXECUTOR plus a startup warn), so there is no silent executor branch behind it.

C — the two optional keys, decided by reading the executors rather than the Zod

key what the executor applies for an absent value declared
ScreenConfig.mode 'create' — screen-nodes.ts:199 builds the ScreenSpec with mode: cfg.mode === 'edit' ? 'edit' : 'create' .default('create')
HttpConfig.method two values — http-nodes.ts:218 inline cfg.method ?? 'GET', :168 durable cfg.method ?? 'POST' ⛔ none

Declaring .default('GET') on method would materialise GET at parse time, the durable arm's own ?? 'POST' would never fire again, and every stored durable callout that omits the method would silently change verb — the defect this card exists to end, pointed the other way. That asymmetry is recorded in both files so the next reader does not "finish the job".

Migration and declaration

  • ADR-0087 D3 semantic entry wait-node-event-config-required under step 18 — a stored block-less wait node has no lossless conversion, because the missing value is an intent no artifact records, so the entry is a structured TODO naming each node to edit (os migrate meta --from 17). Registered by regeneration: packages/spec/src/migrations/registry.ts carries a GENERATED block and check:migration-registry reconciles it against src/migrations/entries/.
  • Changeset: minor on @objectstack/spec and @objectstack/service-automation with a BREAKING banner and its FROM → TO mapping — minor because the launch-window convention ships breaking changes as minor (scripts/check-changeset-no-major.mjs), never major.
  • Clause-②: no, and it was re-measured rather than inherited: check-widening-tells --declaration no over the whole diff exits 0 with zero tells. .default('create') does not widen the accept set — a key that accepted absent/undefined still does; what moves is the OUTPUT type, which gets narrower ('create' | 'edit' where it was 'create' | 'edit' | undefined). The declared-default plumbing it needs already existed (authorable-defaults/automation.json + DEFAULT_CHANGES_BY_MAJOR), so this adds one entry to each rather than a mechanism.

Verification

Gates. All 116 families derived by node scripts/pm/dispatch-gates.mjs --commands from the actual 17-path change set were run, exit codes written to disk before any output was read, and reconciled with --ran: 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN, every code 0. Five needed a second pass and none of them was a red:

family first pass why it was not a verdict after
check:dual-build-cjs-loads · check:i18n · check:type-check-debt exit 3 PREREQUISITE NOT MET — the gate reads built output and the repo was not fully built all 0 after turbo run build over every workspace package (72/72 successful)
check:skill-examples exit 1 refused on packages/client-react/dist holding no declarations — a stated refusal to compute a verdict, not a finding 0
check-plugin-teardown-shape --self-test exit 1 its positive control is pinned to a commit this shallow checkout could not reach; checker-health only, it cannot judge this diff 0 after fetching that commit — 47 cases pass

check:type-check-debt OOM'd on the second attempt too: the gate pins a CI-shaped 6144 MB ceiling and reports that the caller's NODE_OPTIONS is the tighter one. Raised to 8192 for that one command, on that stated reason; it then re-measured 5 ledger entries, 55 raw errors, none above its recorded number.

Tests (affected packages, plus the consumer package already in the diff):

package result
@objectstack/spec 477 files / 13602 tests passed
@objectstack/service-automation 135 files / 1599 tests passed
@objectstack/plugin-approvals 45 files / 738 tests passed
typecheck, all three pass, including each package's check:test-typecheck

Reverse verification of the cross-package type narrowing — one-shot, both legs run from the committed state, the restore leg proven by blob hash rather than by an exit code:

HEAD blob             c9f88d74df30174720a85e1747e7a5ae5c1681ef
mutated blob          220adfac111a1b6e7229ad64a3e8292eb7810095   (probe really on disk)
MUTATED_TSC_EXIT=1    src/builtin/screen-nodes.ts(391,7): error TS2322:
                      Type 'undefined' is not assignable to type '"create" | "edit"'.
RESTORED_TSC_EXIT=0   git diff HEAD empty, blob back to c9f88d74

The probe was ScreenConfigParsed["mode"] = undefined in a consumer that imports the type. It compiles green against the OLD declaration and red against the new one, so the red proves service-automation resolved the rebuilt packages/spec/dist/*.d.ts and not a cached optional mode.

The corpus parses. examples/app-showcase builds its 30 flows through the narrowed contract with no edit, and a repo-wide sweep for type: 'wait' / type: 'boundary_event' in every spelling (TS, JSON, YAML, MDX) found no other fixture needing the block. The one that did — plugin-approvals' ADR-0044 revise-window flow — is in the diff, and its fixture now declares { eventType: 'signal', signalName: 'revision_submitted' } so the send-back refusal it pins is measured against a flow that still registers.

Acceptance notes

  • ⭐ A malformed or zero-valued timerDuration still reproduces the silent park — out of scope for this PR, reported for triage to file. The contract can require the key but cannot evaluate the string: parseIsoDuration answers undefined for 'not-a-duration', for 'P', for 'PT0S' and for '0' exactly as it did for the absent key, so no deadline is computed, no job is armed, no waitUntil is persisted, and the node still returns { success: true, suspend: true }. The repro is live in the tree: wait-node.test.ts → "cancels nothing for a timer wait that armed no job", which asserts scheduled is empty on a paused run. It was left out deliberately — a format refinement narrows the accept set beyond what the ruling covers and adds a validation surface this card's gate families do not carry. A fixture comment on that branch previously claimed it was "Filed separately"; no card exists, and the comment is corrected in this PR rather than left asserting a filing nobody made.
  • In-place fix beyond this card, declared. parseEndNodeConfig's docblock in the same file carried the identical false clause — "Applied at the NODE level so an end nested in a region is checked exactly like a top-level one" — predating this branch (A flow cannot REFUSE with per-record text: the only channel that interpolates is a screen description, and a message-only screen still renders Submit and toasts "completed" #14945). It is the same sentence, the same file, the same mechanism and the same published-to-npm surface, so leaving it would have shipped a file that contradicts itself two docblocks apart. Measured before correcting, not assumed: FlowNodeSchema.safeParse of a top-level end carrying { outcome: 'refused' } and no message answers false; the identical node nested in a loop body passes FlowSchema.safeParse (true) and is refused only by LoopConfigSchema, at body.nodes.0.config.message. The clause is removed and cross-referenced to the one explanation so the two cannot drift. Prose only — no behaviour change, and end config handling is untouched.
  • The four non-timer eventTypes still carry no required resumer key of their own — { eventType: 'signal' } with no signalName parses, and the executor correlates on undefined. Noted, not filed: no separate carrier, and it is the same shape as the row above rather than a second one.

Generated by Claude Code

Contract (packages/spec):
- FlowNodeSchema refuses a `wait` node with no `waitEventConfig` and a
  `boundary_event` node with no `boundaryConfig`, with the key named and the
  remedy in the rejection.
- `waitEventConfig.eventType: 'timer'` requires a non-blank `timerDuration`.
- ScreenConfigSchema.mode declares `.default('create')` — the value the
  executor was measured to apply; HttpConfigSchema.method stays default-free
  because the executor applies GET inline and POST durable.

Executor (service-automation): the `?? {}` block fallback and the `?? 'timer'`
event-type fallback are retired; a node reaching execute without the block is a
logged guard refusal instead of a silent `{ success: true, suspend: true }`.

Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g
Co-authored-by: Claude <noreply@anthropic.com>
…versal

The photograph the card was filed from is now the pin of the opposite
behaviour, channel by channel: the parse refuses, the run fails, nothing is
parked, and the silence is replaced by a `warn` naming the node. Its
`boundary_event` half is unchanged in substance — its fixtures gained the block
the parse now requires, and the "a populated block fails identically" control is
what proves that door moved nothing at execute time.

`wait-node.test.ts`'s teardown case moves from an ABSENT `timerDuration` to an
unparseable one — the shape that still reaches the no-deadline branch now that
the contract requires the key.

Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g
Co-authored-by: Claude <noreply@anthropic.com>
…ontract

"Bare" in that case names the node TYPE — a plain `wait` where the revise window
must be an `approval_revise` node. Without a `waitEventConfig` the flow no longer
registers at all, so the send-back refusal it measures would have been measuring
a flow that does not exist.

Claude-Session: https://claude.ai/code/session_014DBGjJFyndTj766aReCL2g
Co-authored-by: Claude <noreply@anthropic.com>
…he fixture

The teardown fixture's comment claimed the malformed-duration residual was
"Filed separately". No card exists for it: the contract can require
`timerDuration` but cannot evaluate the string, so `parseIsoDuration` returns
`undefined` for a malformed or zero-valued one exactly as it did for the absent
key, and the run parks forever reporting success. The fixture is its repro, and
the PR's acceptance notes carry it for triage to file — a comment that asserts a
filing nobody made is worse than no comment, because the next reader stops
looking.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/spec, touching 13 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-defaults/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via boundaryConfig (literal, a string literal in requireTypeScopedConfig), boundary_event (literal, a string literal in flowNodeObject; a string literal in requireTypeScopedConfig), timerDuration (literal, a string literal in flowNodeObject), waitEventConfig (literal, a string literal in requireTypeScopedConfig))
  • content/docs/deployment/cli.mdx (via api_call (literal, a string literal in requireTypeScopedConfig))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FlowNodeSchema (symbol, a top-level const), timerDuration (literal, a string literal in flowNodeObject), waitEventConfig (literal, a string literal in requireTypeScopedConfig))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-defaults/automation.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 136 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 ba9b4981cd6a111af47bc71741106467ef8f8359 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d472ec14d5aa9caaa0fddca080bfa5c15c0a7910 — the merge of head c6a1998caacba9bffa738bdc137c78acce2c6dd2 into base ba9b4981cd6a111af47bc71741106467ef8f8359, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d472ec14d5aa9caaa0fddca080bfa5c15c0a7910 && git checkout d472ec14d5aa9caaa0fddca080bfa5c15c0a7910
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba9b4981cd6a111af47bc71741106467ef8f8359 c6a1998caacba9bffa738bdc137c78acce2c6dd2 && git checkout -B drift-repro ba9b4981cd6a111af47bc71741106467ef8f8359 && git merge --no-ff c6a1998caacba9bffa738bdc137c78acce2c6dd2

node scripts/docs-audit/affected-docs.mjs --json ba9b4981cd6a111af47bc71741106467ef8f8359

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ba9b4981cd6a111af47bc71741106467ef8f8359 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

In-seat clause-② contract review — PASS, pinned to 14cb2a66b37baba0150e5784ab6376e553a6664d

domain:spec execution seat, session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-14T10:5xZ. This is the record the enqueue gate requires: the diff touches packages/spec/src/**, so the path limb is hit and ⛔ no enqueue may happen without an in-seat review PASS on file — regardless of the claim's Clause-②: no.

CLAUSE_2: no — VERDICT: PASS. The declared no is correct: the diff narrows on every axis it touches and widens on none.

Why this went to a subagent, and how the tier was verified

⚠️ This seat is served by claude-opus-5, which is BELOW CONTRACT_REVIEW_TIER (claude-fable-5-1, read from scripts/pm/dispatch-gates.mjs:10689). Under the downgrade fuse the seat ⛔ does not self-judge clause ②; the review was run by an at-tier subagent and the tier was verified per transcript, ⛔ not from its self-report:

transcript "model" field count
the reviewer 106 × claude-fable-5-1
the os-dev control (same round, this card's implementer) 161 × claude-opus-5

⇒ The extractor discriminates between the two tiers on this harness, so the at-tier reading is a true positive rather than a field that always prints the same string.

The readings that carry the verdict

  • Accept-set differential on the real schemas, both trees — 3,421 inputs (19 node types × 20 waitEventConfig variants × 9 boundaryConfig variants): main accepts 1141, head accepts 821. WIDENED (head accepts what main refused): 0. NARROWED: 320. No sibling arm was loosened to compensate — the unchanged cells were enumerated and match main exactly.
  • .default('create') on ScreenConfig.mode — 14 inputs incl. absent, undefined, null, '', 'CREATE', 1, unknown key: inputs whose accept verdict differs: 0. Only the OUTPUT moves (absent ⇒ mode: "create" materialised); the published type narrows from 'create'|'edit'|undefined to 'create'|'edit'. The output-mode JSON Schema gaining required: ["mode"] is the pre-existing convention for every .default() key, measured against the ScheduleState.status precedent on main — ⛔ not introduced here. No enum gains a member.
  • .default('GET') is not in this diff at all — HttpConfigSchema.method changed docblock only; 6/6 inputs identical verdict and output, emitted JSON Schema byte-identical in both io modes.
  • New symbols / codes / keys: one export const entry in the new semantic entry (generator input; migrations/index.ts re-exports only registry.js, api-surface/automation.json untouched). requireTypeScopedConfig is module-private. Issue codes are Zod code: 'custom'; the executor reuses the pre-existing refuseNode. No new StandardErrorCode.
  • ADR-0087 entry registered by REGENERATION, not by hand: build-migration-registry --self-test --check exits 0 on the head extract (217 semantic vs main's 216); positive control — mutating one word in the entry file flips it to ✗ stale, EXIT=1.
  • Gate: check-widening-tells --declaration no exits 0 over the diff. Controls that make that zero a reading: --self-test 269 cases pass, and a synthetic one-key diff on flow.zod.ts correctly fires ✗ T1 … EXIT=4. ⚠️ The two known weaknesses of this gate (the retiredKey() false positive of [finding] check-widening-tells fires T1 on a retiredKey() tombstone line, so every ADR-0087 key retirement reads as a clause-2 widening for the one reason the accept set shrank #17955, the re-declared-key T1 blind spot of the widening refusal offers a remedy with no reader — "explain in the claim" moves no exit code (the T1 re-declared-key half did NOT reproduce) #17848) were checked and neither applies: no retiredKey( line is added, and the only re-declared key line is a pre-existing mode:.

Independence

Implemented-by: the mode:subagent dev on branch claude/issue-17928-wait-node-config-required. Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6 (this seat), through an isolated at-tier review subagent fed only the card, its thread, the PR body and the tree. ⚠️ Stated plainly rather than implied: the implementer and the reviewer were dispatched from this same seat session, so this is in-seat review with tier separation, ⛔ not cross-seat independence.

⚠️ Scope of this PASS

It is pinned to 14cb2a66. A prose-only patch round is in flight (see the REWORK below); a comment/docblock change cannot move the clause-② axis, but this seat will confirm the patch is prose-only at the new head before enqueue rather than assuming it.


Generated by Claude Code

…e only

Review round on the draft PR caught a FALSE sentence this branch shipped on a
published surface. `packages/spec` lists `src/**/*.zod.ts` in its `files[]`, so
the docblock ships to npm verbatim.

- `requireTypeScopedConfig` claimed the refusal ran "at the NODE level, so a
  `wait` nested in an ADR-0031 region is checked exactly like a top-level one".
  It does not. `parseFlowNodeRegions` leaves a refused region RAW, so
  `FlowSchema.safeParse` of a flow whose `loop` body holds a block-less `wait`
  answers `true`; the REGION contract refuses it at
  `body.nodes[i].waitEventConfig`. This branch's own test pinned that all along
  and the docblock contradicted it. Rewritten to say which door, and when.

- The same false clause sat in `parseEndNodeConfig`'s docblock (predates this
  branch, same file, same sentence, same mechanism). Measured rather than
  assumed: a top-level `end` carrying `{ outcome: 'refused' }` and no `message`
  is refused by `FlowNodeSchema`, the identical node nested in a `loop` body
  passes `FlowSchema` and is refused only by `LoopConfigSchema` at
  `body.nodes.0.config.message`. Corrected in place and cross-referenced to the
  one explanation, so the two cannot drift.

- The `timerDuration` refinement sits on the BLOCK and is not gated on
  `type: 'wait'` — measured: a `start` node spelled
  `waitEventConfig: { eventType: 'timer' }` is refused at head and parses on
  main, with both controls green. The changeset, the ADR-0087 entry and the
  refinement's own comment now say so instead of reading as wait-only.

⛔ No behaviour change: every changed line in the three `.ts` files is a comment
or a prose string literal, and `registry.ts` is the regenerated mirror of the
entry. The accept set is untouched.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 14, 2026
@os-warren
os-warren marked this pull request as ready for review September 14, 2026 12:49
@os-warren
os-warren enabled auto-merge September 14, 2026 12:49
@os-warren
os-warren disabled auto-merge September 14, 2026 12:50
@os-warren
os-warren enabled auto-merge September 14, 2026 12:50
@os-warren
os-warren added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit cb1f274 Sep 14, 2026
37 checks passed
@os-warren
os-warren deleted the claude/issue-17928-wait-node-config-required branch September 14, 2026 13:15
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ait, instead of parking the run forever (objectstack-ai#18370)

Fixes objectstack-ai#18179

The objectstack-ai#17928 hole reached through a different door. Under `eventType:
'timer'` the contract requires a non-blank `timerDuration`, but the key
is `z.string()` and no schema can evaluate the string:
`'not-a-duration'`, `'1 hour'`, `'P'`, `'PT0S'`, `'PT0H0M0S'`, `'0'` and
`'-5'` are all documents that **save**, and `parseIsoDuration` answers
`undefined` for every one of them — exactly as it did for the absent
key. This PR makes the executor refuse them loudly instead of parking
the run forever while reporting success.

## Premise re-check on this branch's base, `b57671edc1` — every cited
line holds

The dispatch measured at `1411cf2c6f`; this branch forked at
`b57671edc1` (`origin/main` is still `b57671edc1` as this PR opens).
Re-measured here rather than carried:

| cited | dispatch | measured on `b57671edc1` |
|---|---|---|
| `parseIsoDuration` declaration | `:526` | `:526` OK |
| no deadline computed (`durationMs && durationMs > 0`) | `:298` |
`:298` OK |
| no `waitUntil` persisted (`const output = at ? …`) | `:304` | `:304`
OK |
| no job armed (`if (job && runId != null && at)`) | `:307` | `:307` OK
|
| the no-job-service fallback (`else if (!job)`) | `:329` | `:329` OK |
| returns `{ success: true, suspend: true }` | `:337` | `:337` OK |
| the tree pins it as expected | `wait-node.test.ts:332` | `:332` OK |

## The state the old path left behind

With a job service **answering**, neither branch could run: the arming
guard fails on the deadline, and the `else if (!job)` fallback fires
only when the job service is **missing**. Control fell straight to the
suspending return, whose comment points at recovery through "a later
boot's re-arm pass, when the deadline was persisted" — and `:304` had
just established that it was not.

Measured through a real `engine.execute()` run, not read off the source:

| channel | before | after |
|---|---|---|
| `engine.execute()` | `{ success: true, suspend: true }`, status
`paused` | run **fails**, node status `failure` |
| suspended runs | one, parked forever | none — the run never suspends |
| persisted `waitUntil` | absent, so the cold-boot re-arm pass is blind
to the run | nothing persisted, because nothing parks |
| scheduled jobs | none, with a job service **answering** | none, and
the run is refused instead |
| log lines during the run | **0 at any level** | one `warn` naming the
node, the value and the remedy |
| `errorClass` | n/a — it reported success | `'guard'`, so a `fault`
edge cannot route it |

A warning-only fix would have moved the last row and nothing else; the
run would still park forever.

## What lands

**`wait-node.ts` — the timer branch refuses before it suspends.** When
`parseIsoDuration` answers `undefined` (which already covers zero and
negative), the node emits a `warn` and returns `refuseNode(...)`,
mirroring what PR objectstack-ai#18175 (`cb1f27434b`, on `main`) already did for a
stored absent block. The refusal is `guard`-class for the same reason:
the metadata is wrong, re-running changes nothing, and a `fault` edge
must not be able to route a metadata defect into a handler that then
reports success.

- **`warn`, not `error`**, by AGENTS.md's degradation rule and for the
same reason the block-less refusal above it is `warn`: the failure is
handed to the **caller** — the run fails and says so — so nothing looks
normal from the outside and this is not a durability degradation.
- **The offending value travels with the complaint**, quoted through
`JSON.stringify` rather than spliced raw. `timerDuration` is
author-controlled foreign text arriving on a log record, and a value
carrying a newline would split the alarm into physical lines of which
only the first carries its level — the objectstack-ai#5737 hazard, arriving from the
authoring side instead of the driver side. Pinned by its own test.
- **Zero and negative are the same verdict, deliberately not a separate
one.** `'PT0S'` is not a short wait; it is a deadline already past, and
it parks just as permanently.
- **`output` is now unconditional in that branch.** The old `at ? {
output: … } : {}` spread existed so `output` would be absent rather than
a present key holding `undefined` when no deadline was computed. That
distinction still matters and is still pinned (in
`absent-config-node-characterization.test.ts`, on the signal branch);
what is gone is the arm that reached it. The two degraded returns still
carry the deadline — they lose the auto-resume, never the `waitUntil` a
later boot re-arms from.

**`wait-node.test.ts` — the pin that asserted today's behaviour as
expected moves, and the moved pin is the evidence.** `:332` ("cancels
nothing for a timer wait that armed no job") kept its subject — teardown
— and swapped its fixture: it used `timerDuration: 'not-a-duration'`,
and its own comment named that fixture as the repro for a residual with
no card. That card is objectstack-ai#18179 and this PR discharges it, so the fixture
moved to a job service whose `schedule` throws, the one route left to a
degraded `timer:pause` correlation on a host that **has** a job service.

A new block pins the reversal on every channel in the table above,
across all seven unusable spellings, with a `PT1H` control that still
suspends, arms the one-shot and persists the deadline.

## Scope — and the half deliberately NOT decided here

The queued scope was the runtime half only, and that is all this PR
contains. A **format refinement on `timerDuration`** narrows a published
accept set; decision batch objectstack-ai#127 item 5 requires the block and the key
and 「⛔ does not rule on the key's lexical validity」, so there is no
ruling behind a narrowing. No file under
`packages/spec/src/automation/**` is touched:
`builtin-node-config.zod.ts`, `flow.zod.ts`, `node-executor.zod.ts` and
`schemaless-node-config.zod.ts` are all unchanged, and every one of the
seven fixtures above still parses clean — which the new premise leg
asserts explicitly, so this PR's own tests would go red if the accept
set ever moved underneath them.

## Clause-②: no

Clause-②: no — this PR widens nothing. The accept set does not move and
no published surface grows: a run that used to report `success: true`
forever is refused loudly instead.

Nothing new is exported: the refusal reuses `refuseNode` from
`guard-refusal.ts`, which this file already imported; no new error code,
no new exported helper, no new schema refinement.
`packages/services/service-automation`'s `exports` map is
byte-identical.

## Evidence

Every command below was run on this branch, most of them through
`scripts/pm/os-verify-lock.sh`.

- **Dependency-closure build** — `pnpm --workspace-concurrency=2
--filter '@objectstack/service-automation^...' build` :: exit 0.
- **Package tests** — `pnpm --filter @objectstack/service-automation
test` :: exit 0 — **136 files, 1625 tests passed**.
- **Package typecheck** — `pnpm --filter @objectstack/service-automation
typecheck` :: exit 0, including `check:test-typecheck` (0 files / 0
errors held in the debt ledger).
- **Gate families** — derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no hand-written path
list), run, then reconciled with `--ran`: **62 derived, 62 run, 0
NOT-MEASURED, 0 UNRUN**, all carrying recorded exit codes. Five of them
first answered `exit 3 PREREQUISITE NOT MET`; they were re-run to a real
`exit 0` after `pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*'` (72/72 tasks) rather than recorded as passes.
- **Repo-wide lint, not a narrowed one** — `pnpm lint` (`eslint .
--no-inline-config`) :: exit 0 over the whole population, so no
narrowing needs defending.
- **Reverse verification** — the fix was committed first, then `git
restore --source=b57671edc1` put the pre-fix file back (tree only, never
the index; 0 staged paths confirmed). On-disk landing proven by anchor
counts, not by the editor's exit code: the `REVERSAL (objectstack-ai#18179)` marker
went 1 -> 0 and the old `const at = durationMs && durationMs > 0` anchor
0 -> 1, and the mutated blob hash equalled the base blob hash exactly.
**`src/builtin/wait-node.test.ts` then failed: 8 failed | 28 passed
(36).** Restored with `git checkout HEAD -- THE_FILE`; the restored blob
hash equalled the HEAD blob hash, `git diff HEAD` was empty, and the
re-run was **36 passed (36)**. The subject resolves through a relative
import inside its own package, so no `dist` leg applies to this
ablation. The ablation script carried a `trap … EXIT INT TERM` restore
on an absolute path throughout.
- Direction, predicted before the run and observed: **turns red**, and
only the new block did. The premise leg, the teardown pin and the `PT1H`
control all stayed green under the ablation — they are not about the
fix.
- **Changeset — measured, not assumed.**
`@objectstack/service-automation` is published and its `files` is
`["dist","README.md","CHANGELOG.md"]` (no `src`). After building the
package: the subject text `is not a usable wait` reaches **both**
published bundles (`dist/index.js` 2 hits, `dist/index.cjs` 2 hits);
positive control `a wait node must declare what resumes it` (text that
already ships) 1 hit in each; negative controls `REFUSED, not parked`
and `scheduleThrows` (both test-only) 0 hits in each. => it publishes =>
`.changeset/18179-timer-duration-unusable-refusal.md`, `patch`, named
for the card so it cannot collide with another PR's pending note.
- **Control bytes** — `grep -naP` over the changed files for the C0/C1
range: no match, beside `pnpm check:nul-bytes` :: exit 0.

## Declared file face

- `packages/services/service-automation/src/builtin/wait-node.ts` — the
timer branch
- `packages/services/service-automation/src/builtin/wait-node.test.ts` —
the moved pin plus the reversal block
- `.changeset/18179-timer-duration-unusable-refusal.md`

Three files, and nothing else. In particular **not**
`packages/services/service-automation/package.json` or
`vitest.config.ts`, which PR objectstack-ai#18198 owns right now — the fix needed
neither, and no file under `src/builtin/` overlaps any of the three
concurrent faces named at dispatch.

## Acceptance notes

Recorded here rather than filed, per the scope rules.

**1. The sibling that rides with objectstack-ai#18179 — and a correction to how it was
described.** The dispatch (and the card) say that `{ eventType: 'signal'
}` with no `signalName` makes the executor "correlate on `undefined`".
Re-measured on `b57671edc1`, it does not: `wait-node.ts:342` on
`b57671edc1` reads ``const signal = String(wec.signalName ??
`wait:${node.id}`)``, so the pause carries a synthesized `wait:pause`
correlation. That difference is load-bearing, and it is why the sibling
is **not** discharged inside scope 1:

- A timer wait declares a deadline and then has no mechanism that can
ever arrive. A signal / webhook / manual / condition wait declares an
**external** resumer, and the documented external route still works —
`resumeAuthority: 'any'`, the REST resume door objectstack-ai#3801 deliberately leaves
open for `wait`, and `resume(runId)` — which the existing pin "cancels
nothing for a signal wait" exercises end to end. The run is parked but
reachable, not parked and unreachable.
- Refusing an absent `signalName` would bite `eventType: 'manual'`,
where absence is the normal authoring shape for an operator-resumed
pause, and `'condition'`. Deciding which of the four must carry the key
is a question about the contract's accept set — scope 2 — not a runtime
bug the runtime may settle by itself. Reported rather than widened, as
the dispatch directs. ⛔ No card filed for it; this PR is its carrier.
- One narrower shape found while measuring it, recorded and **not**
acted on: `signalName: ''` passes `z.string()` and survives `??` (which
catches only nullish), so `{ eventType: 'signal', signalName: '' }`
suspends with an **empty** correlation. Same scope-2 question, same
answer: reported, not decided.

**2. `runId == null` on the timer branch is still a silent degrade.**
With a job service present but no run id, neither `if (job && runId !=
null)` nor `else if (!job)` runs, so the node suspends with no line
logged. Re-measured, it is not reachable through the engine:
`engine.ts:10600` sets `variables.set('$runId', runId)` unconditionally
in the seeding path. Not a class (a) finding, therefore noted, not
filed. Successor: none — there is no PR or person queued on this file
after this one; it is recorded so the next reader of this branch does
not have to re-derive it.

Authored by the `os-dev` round of session
`session_01KB5PFtxuy1x3dcR5gxudx6`.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants