Repository navigation
fix(automation): require the wait / boundary_event config block at the contract, retire the executor's silent timer default - #18175
Conversation
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>
…angeset 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>
…it-node-config-required
…it-node-config-required
…it-node-config-required
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 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
|
In-seat clause-② contract review — PASS, pinned to
|
| 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
waitEventConfigvariants × 9boundaryConfigvariants):mainaccepts 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')onScreenConfig.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'|undefinedto'create'|'edit'. The output-mode JSON Schema gainingrequired: ["mode"]is the pre-existing convention for every.default()key, measured against theScheduleState.statusprecedent on main — ⛔ not introduced here. No enum gains a member..default('GET')is not in this diff at all —HttpConfigSchema.methodchanged 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 entryin the new semantic entry (generator input;migrations/index.tsre-exports onlyregistry.js,api-surface/automation.jsonuntouched).requireTypeScopedConfigis module-private. Issue codes are Zodcode: 'custom'; the executor reuses the pre-existingrefuseNode. No newStandardErrorCode. - ADR-0087 entry registered by REGENERATION, not by hand:
build-migration-registry --self-test --checkexits 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 noexits 0 over the diff. Controls that make that zero a reading:--self-test269 cases pass, and a synthetic one-key diff onflow.zod.tscorrectly fires✗ T1 … EXIT=4.⚠️ The two known weaknesses of this gate (theretiredKey()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: noretiredKey(line is added, and the only re-declared key line is a pre-existingmode:.
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.
⚠️ 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>
…it-node-config-required
…it-node-config-required
…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>
Fixes #17928
A
waitflow node with nowaitEventConfigblock 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 reachesexecute, and settles the two default-free optional keys the same ruling covers.claude/issue-17928-wait-node-config-requiredwhen 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:
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)FlowNodeSchemanow requireswaitEventConfigon atype: 'wait'node andboundaryConfigon atype: 'boundary_event'node, and the inlinewaitEventConfigblock inflowNodeObject()requires a non-blanktimerDurationundereventType: 'timer'. There is noWaitEventConfigSchemaexport — 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 atnodes[i].waitEventConfigwith the remedy in the message.parseFlowNodeRegionsparses each region slot withsafeParseand, on a refusal, leaves that region raw and continues — a policy that predates this change — soFlowSchema.safeParseof a flow whoseloopbody holds a block-lesswaitanswerstrue. What refuses it is the region contract (LoopConfigSchema/ParallelConfigSchema/TryCatchConfigSchema) atbody.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 inflow.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'sacceptanceCriteriahas said so from the start.timerDurationrequirement is BLOCK-scoped, not node-type-scoped. It sits on thewaitEventConfigblock, so any node type carrying that key is held to it:FlowNodeSchema.safeParseof astartnode spelledwaitEventConfig: { eventType: 'timer' }answersfalseat this head andtrueonmain, with both controls green (the same node withtimerDuration: 'PT1H', and a plainstartwith no block, bothtrue). It only ever narrows, and the block is inert on node types no executor reads it from, so the practical reach iswait— 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 declaredeventType.B — the executor follows the contract (
packages/services/service-automation)wait-node.tscarried(node.waitEventConfig ?? {})andString(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 reachesexecutewithout the block (a stored pre-migration document on a path that skipped the parse) is now a guard refusal —errorClass: 'guard', so afaultedge 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 answeringoutputas a present key holdingundefined, and it is absent when no deadline was computed.boundary_eventgets the contract half only: the runtime registers no executor for that type at all (NO_EXECUTORplus 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
ScreenConfig.mode'create'—screen-nodes.ts:199builds the ScreenSpec withmode: cfg.mode === 'edit' ? 'edit' : 'create'.default('create')HttpConfig.methodhttp-nodes.ts:218inlinecfg.method ?? 'GET',:168durablecfg.method ?? 'POST'Declaring
.default('GET')onmethodwould materialiseGETat 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
wait-node-event-config-requiredunder step 18 — a stored block-lesswaitnode 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.tscarries a GENERATED block andcheck:migration-registryreconciles it againstsrc/migrations/entries/.minoron@objectstack/specand@objectstack/service-automationwith a BREAKING banner and its FROM → TO mapping —minorbecause the launch-window convention ships breaking changes asminor(scripts/check-changeset-no-major.mjs), nevermajor.Clause-②: no, and it was re-measured rather than inherited:check-widening-tells --declaration noover the whole diff exits 0 with zero tells..default('create')does not widen the accept set — a key that accepted absent/undefinedstill 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 --commandsfrom 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 code0. Five needed a second pass and none of them was a red:check:dual-build-cjs-loads·check:i18n·check:type-check-debtPREREQUISITE NOT MET— the gate reads built output and the repo was not fully built0afterturbo run buildover every workspace package (72/72 successful)check:skill-examplespackages/client-react/distholding no declarations — a stated refusal to compute a verdict, not a finding0check-plugin-teardown-shape --self-test0after fetching that commit — 47 cases passcheck:type-check-debtOOM'd on the second attempt too: the gate pins a CI-shaped 6144 MB ceiling and reports that the caller'sNODE_OPTIONSis 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):
@objectstack/spec@objectstack/service-automation@objectstack/plugin-approvalscheck:test-typecheckReverse 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:
The probe was
ScreenConfigParsed["mode"] = undefinedin a consumer that imports the type. It compiles green against the OLD declaration and red against the new one, so the red provesservice-automationresolved the rebuiltpackages/spec/dist/*.d.tsand not a cached optionalmode.The corpus parses.
examples/app-showcasebuilds its 30 flows through the narrowed contract with no edit, and a repo-wide sweep fortype: '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
timerDurationstill 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:parseIsoDurationanswersundefinedfor'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, nowaitUntilis 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 assertsscheduledis 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.parseEndNodeConfig's docblock in the same file carried the identical false clause — "Applied at the NODE level so anendnested 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 screendescription, 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.safeParseof a top-levelendcarrying{ outcome: 'refused' }and nomessageanswersfalse; the identical node nested in aloopbody passesFlowSchema.safeParse(true) and is refused only byLoopConfigSchema, atbody.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, andendconfig handling is untouched.eventTypes still carry no required resumer key of their own —{ eventType: 'signal' }with nosignalNameparses, and the executor correlates onundefined. 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