fix(sdk): lower trigger keys to the kernel dialect, and add a scheduled trigger - #151
Conversation
A relayflow could not be scheduled. `grep -rniE "cron|schedule|interval|timer"`
over sdk/src/spec.ts and sdk/src/compile.ts returned nothing; every shipped
trigger was fed by a poller reacting to something external.
RFC-0001 already decided the shape, so a schedule is an EVENT SOURCE, not a
kernel feature: gate 2 "proves triggers are entry conditions, not schedulers",
and the 2026-08-27 dogfood run is on record for "a cron trigger reported
`succeeded` into a void with no worker enrolled". There is no `cron:` field on
the spec and no Rust changed.
sdk/src/tick-source.ts sits beside hn-poller and dir-watcher-poller and submits
`flows.tick` through the same event.submit path, so it inherits rather than
reimplements the kernel's dedupe claim and its liveness sweep.
Duplicates and skips are separate mechanisms, and neither covers for the other:
- The dedupe key is derived from (schedule_id, scheduled_for_ms) — the slot's
grid instant, never wall-clock-at-emit. The kernel's
(flow_key, subscription_id, dedupe_key) claim then makes a double-fire, a
re-delivery, two racing pollers, and a poller restart with a lost cursor all
idempotent. Restart-idempotency belongs to the key, not to the cursor.
- The cursor emits every slot between the last emitted and now, so a sleeping
poller backfills instead of silently dropping slots. It advances only after a
successful submit — dir-watcher's `seen` discipline. Slots beyond maxCatchUp
are reported in `skippedSlots`, not dropped quietly.
Liveness: the kernel already implements the RFC's RelayCron claim + stale_after
reconciliation; what was missing was the authoring half. `staleAfterMs` was
unauthorable through the SDK, so every flow silently inherited the 5-minute
engine default. It is now declarable and bounds-checked against the same i64
limit the kernel enforces. Not closed: a schedule provisioned but never fired
is still undetected (the kernel's own documented gap), and nothing restarts a
dead schedule.
Also fixes a pre-existing P0 this work could not proceed without: toKernelSpec
spread `flow.triggers` through untouched, so every event subscription reached
the kernel in camelCase and relayflowd — deny_unknown_fields over snake_case —
refused the spec outright:
malformed run spec: unknown field `dedupeKeyTemplate`, expected one of
`id`, `executor`, `event_type`, `pattern`, `dedupe_key_template`,
`stale_after_ms`
Every event-triggered flow in testdata/ was unauthorable through the supported
SDK path; the committed snake_case fixtures hid it because no test compiled a
triggered flow and compared it to one. The fixed compiler now reproduces those
fixtures byte-for-byte, and spec-parity pins all of them.
Worked example: testdata/tick-heartbeat.flow.yaml with fixtures generated
through the SDK compiler. Four missed slots produce four distinct successful
runs, each reporting its own grid instant and lag; a re-delivery produces none.
Full evidence, both gate baselines and mutation verification in
ops/reviews/20260903-scheduled-trigger-design.md.
Gates (RELAYFLOWD_BIN pinned to this worktree's build):
tsc --noEmit 0 -> 0
tsc -p tsconfig.tests.json 0 -> 0
vitest run 370 passed/3 skipped -> 408 passed/3 skipped
cargo test --workspace 100 passed -> 100 passed (identical test set)
38 test names added, 0 removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
|
Warning Review limit reachedNext included review available in 59 minutes. View limit detailsLimit details: You’ve used the included review currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (14)
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing. Comment |
Signoff returned REVIEW_PASSED at 969ed44 with no P0 and no P1. These three are fixed anyway because all three reproduce the exact failure class this PR exists to address — RFC-0001's "a flow that is never triggered is silently zero". A scheduled-trigger primitive with paths that produce a silently-zero schedule undercuts the thing it is for. P2-1 — a skip could vanish when the same poll failed. The cursor was advanced past `skippedSlots` BEFORE the emit loop, so a submit failure discarded the returned result (the only place a skipped slot is ever recorded, since it never reaches the kernel) while the cursor had already moved past the evidence. Seven skipped slots, no report anywhere, not re-derivable next poll. The module's own guarantee is that a skip cannot be silent; in that path it was. The pre-advance is gone, and a failure now throws TickEmitError carrying emittedSlots, outcomes and skippedSlots. Three paths account for a skip and none drops it: a submit succeeds and the result carries it; a submit throws and the error carries it; nothing was submitted, so the cursor never moved and the next poll re-derives the identical due range. The reviewer noted this becomes P1 the moment the CLI runner lands and must be fixed as its prerequisite — so it is now met. P2-2 — a NaN grid made a schedule permanently and silently zero. `epochMs` and `nowMs` were unvalidated while `intervalMs` was, and the asymmetry was the bug: a NaN made slotFor() return NaN, every comparison against it false, and the poll returned an empty result — no submit, no skip, no throw. An infinity died with a raw `Invalid array length`. Both now go through requireNonNegativeInteger, which covers NaN, both infinities, negatives and non-integers in one check. A grid that cannot be computed refuses at the call, by name. P3 — `9_223_372_036_854_775_807` does not express i64::MAX in a double; it rounds UP to 2^63, so `value > MAX_STALE_AFTER_MS` admitted exactly the one value the kernel refuses. The SDK/kernel-agreement failure this file exists to prevent, in miniature. The bound is now Number.MAX_SAFE_INTEGER: above 2^53 a JS number cannot name a specific integer, so a larger budget could not cross the boundary faithfully even if the kernel would take it. 2^53 ms is ~285,000 years. Red captured first for all three. Mutation-verified, both files restored byte-for-byte (tick-source.ts a0f2d1e4..., validate.ts 9986dc54...): M3 re-add the cursor pre-advance -> 1 failed (expected 107 to be 100) M3b throw cause, not TickEmitError -> 1 failed (not an instance of TickEmitError) M4 drop the epochMs/nowMs guards -> 8 failed M5 restore the i64 literal bound -> 2 failed (expected true to be false) M3 and M3b failing one test each, and different tests, shows the two halves of the P2-1 fix are independently load-bearing rather than one mechanism counted twice. Gates (RELAYFLOWD_BIN pinned to this worktree's build): tsc --noEmit exit 0 tsc -p tsconfig.tests.json exit 0 vitest run 423 passed, 3 skipped, 0 failed (was 408/3/0) cargo test --workspace 100 passed, identical test set to baseline +15 test names, 0 removed. Fixtures untouched: `git diff --stat testdata/` is empty and spec_hash is unchanged at 0c3d089f..., so the compiled dialect is identical to the signed-off head. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
P2-1, P2-2, P3 fixed at
|
| Mutation | Reverted | Result |
|---|---|---|
| M3 | re-add the cursor pre-advance | 1 failed — expected 107 to be 100 |
| M3b | throw cause instead of TickEmitError |
1 failed — not an instance of TickEmitError |
| M4 | drop both requireNonNegativeInteger calls |
8 failed |
| M5 | restore the i64 literal bound | 2 failed — expected true to be false |
M3 and M3b failing one test each, different tests, shows the two halves of the P2-1 fix are independently load-bearing rather than one mechanism counted twice.
Gates at ad4b431
| Gate | Baseline 990093b |
969ed44 |
ad4b431 |
|---|---|---|---|
tsc --noEmit |
0 | 0 | 0 |
tsc -p tsconfig.tests.json |
0 | 0 | 0 |
vitest run |
370p/3s/0f | 408p/3s/0f | 423p/3s/0f |
cargo test --workspace |
100 passed | 100 passed | 100 passed, identical test set |
Per-file since 969ed44: tick-source.test.ts +13 (20→33), validate.test.ts +2 (42→44). +15 names, 0 removed.
Fixtures untouched. git diff --stat testdata/ is empty and spec_hash is unchanged at 0c3d089f0075c53442c5c2241ada734af5e450a2b558c16030aaf1d1e29127ff — these fixes are behavioural and validation-side only, so the compiled dialect is byte-identical to the signed-off head.
Two corrections to my own disclosures
The signoff verified two things I declined to claim, and I was being harder on myself than the evidence required:
- Daemon SIGKILL mid-backfill — verified on the reviewer's live harness: dedupe survives, 3 slots → 3 journals. Recorded as the reviewer's evidence, not mine.
- Cross-host clock skew — my suspicion that it duplicates runs was wrong. Skew shifts when a slot fires, not how many times, because a slot's identity is a property of the grid and not of either host's clock.
Both corrected in ops/reviews/20260903-scheduled-trigger-design.md rather than deleted, since the original claims are in the description above.
The deployment limit stated in the description is unchanged: no CLI runner ships, so agent-relay cloud schedules will still be empty after merge. The primitive is proven, not deployed.
Merging — basis at
|
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's shape a fourth time in the same function. #151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec -- authoring keys lowered into the kernel's snake_case dialect, which is authoring sugar becoming a different object at the boundary, exactly like `output:`. This branch had changed the same lines from `flow.*` to `compiled.*` for the snapshot guard. Taking either side wholesale reverts the other; the resolution is `compiled.triggers.map(toKernelTrigger)`. The prediction from section 10 held: validateSpec returns ok=true and `flows check` returns CHECK PASSED exit=0 whether or not the lowering happened. Only compileYaml + toKernelSpec, read against the kernel object, shows eventType -> event_type. Unlike the first three traps this one also has committed fixtures behind it -- #151 pinned a canonical form and a spec hash -- so a reverted lowering would go red in the suite too. Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned fixtures, 6 changed by me with every deletion attributed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's shape a fourth time in the same function. #151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec -- authoring keys lowered into the kernel's snake_case dialect, which is authoring sugar becoming a different object at the boundary, exactly like `output:`. This branch had changed the same lines from `flow.*` to `compiled.*` for the snapshot guard. Taking either side wholesale reverts the other; the resolution is `compiled.triggers.map(toKernelTrigger)`. The prediction from section 10 held: validateSpec returns ok=true and `flows check` returns CHECK PASSED exit=0 whether or not the lowering happened. Only compileYaml + toKernelSpec, read against the kernel object, shows eventType -> event_type. Unlike the first three traps this one also has committed fixtures behind it -- #151 pinned a canonical form and a spec hash -- so a reverted lowering would go red in the suite too. Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned fixtures, 6 changed by me with every deletion attributed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…ow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's shape a fourth time in the same function. #151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec -- authoring keys lowered into the kernel's snake_case dialect, which is authoring sugar becoming a different object at the boundary, exactly like `output:`. This branch had changed the same lines from `flow.*` to `compiled.*` for the snapshot guard. Taking either side wholesale reverts the other; the resolution is `compiled.triggers.map(toKernelTrigger)`. The prediction from section 10 held: validateSpec returns ok=true and `flows check` returns CHECK PASSED exit=0 whether or not the lowering happened. Only compileYaml + toKernelSpec, read against the kernel object, shows eventType -> event_type. Unlike the first three traps this one also has committed fixtures behind it -- #151 pinned a canonical form and a spec hash -- so a reverted lowering would go red in the suite too. Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned fixtures, 6 changed by me with every deletion attributed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…ow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's shape a fourth time in the same function. #151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec -- authoring keys lowered into the kernel's snake_case dialect, which is authoring sugar becoming a different object at the boundary, exactly like `output:`. This branch had changed the same lines from `flow.*` to `compiled.*` for the snapshot guard. Taking either side wholesale reverts the other; the resolution is `compiled.triggers.map(toKernelTrigger)`. The prediction from section 10 held: validateSpec returns ok=true and `flows check` returns CHECK PASSED exit=0 whether or not the lowering happened. Only compileYaml + toKernelSpec, read against the kernel object, shows eventType -> event_type. Unlike the first three traps this one also has committed fixtures behind it -- #151 pinned a canonical form and a spec hash -- so a reverted lowering would go red in the suite too. Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned fixtures, 6 changed by me with every deletion attributed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…ow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* feat(sdk): settle data and code gate contract
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed on invalid gates
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): bundle JSON Schema draft metadata
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed at gate boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(runtime): close gate boundary execution holes
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): reject proxies at exported boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): bound JSON Schema declarations so validation terminates
A `json_schema` gate whose `$ref` graph cycles through only in-place
applicators compiles cleanly and then recurses without bound the first time
it validates an output. In Rust that aborts the process, so `run.start`
accepted the spec, created the journal, ran the step's command, and then took
relayflowd down with SIGABRT — leaving a run stuck `running` that re-executed
its effect on every resume (4 executions of one logical step, no
`completionReason`, no `step.completed`). RFC-0001 covenant 2 and gate 1.
A stack overflow cannot be caught, so the bound is structural and runs before
the declaration is accepted: reject a reference cycle that re-applies to the
same instance and therefore makes no progress. Cycles through a child
applicator (`properties`, `items`, `prefixItems`, ...) consume one level of
the instance per step and stay legal, so ordinary recursive schemas are
unaffected.
`kernel/relayflowd-core/src/schema.rs` and `sdk/src/json-schema-bound.ts`
implement the same rule and are pinned to a shared corpus in
`testdata/json-schema-bound-cases.json`, so the kernel and the SDK agree on
which schemas are legal by construction rather than by coincidence of Ajv's
catchable RangeError and Rust's uncatchable abort. That also closes the
reported SDK/kernel divergence on a self-recursive `$defs`. Every corpus
refusal compiles cleanly in `jsonschema`, which is what makes the tests test
the bound and not the mechanism. `verify` now compiles through the same gate.
Also from the same review:
- `canonicalize`/`specHash` are exported unknown-input helpers, so they carry
the snapshot guard the rest of the exported surface already has, and each
key is read exactly once instead of twice (a demonstrated getter TOCTOU).
- A `json_schema` gate that accepts every output (`{}`, `true`, annotations
only) is still legal, but `flows check` marks the line and preflight emits a
`vacuous_gate` warning: a gate that judges nothing must not read like one
that judges something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): gate output declarations through the same schema bound
Rebase onto main brought in #133's `output:` sugar, which lowers to a
`json_schema` gate at compile time but was only checked with `isObject`. So an
`output` schema the kernel refuses passed `flows check` and was reported as a
gate — an unbounded `$ref` cycle included. Route it through `jsonSchemaError`,
the same gate a hand-written `verification: {type: json_schema}` clears.
Also adapts one test to main's boundary contract rather than deleting it:
`preflight` now returns a named `invalid_spec` refusal where it used to throw,
so the proxy-boundary test accepts either refusal shape and treats "returned a
usable result" as a failure. Trap and getter counters are untouched.
Adds ops/reviews/20260903-pr139-repair-0903.md: the red four-execution ladder,
the structural fix, the shared kernel/SDK corpus, the three-path `output`
proof, the silently-merged-file enumeration, and every gate with literal
output. It also records that origin/main moved from 3da71e2 to 990093b (#136)
mid-work and that this branch is rebased onto 990093b, not the pinned SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): name the second silent-revert trap and characterise the kernel exit-101
Two conflict resolutions on this rebase could have silently reverted freshly
merged behaviour, not one. The brief named step-fields.ts; compile.ts's
compileStep is the sharper one, because neither validateSpec nor `flows check`
can see it — both still report a healthy gate when the `output:` lowering has
been reverted to the raw authored gate. Only compileYaml + toKernelSpec, read
against the kernel verification object, tells "the key was accepted" apart from
"the key became a gate". Records that, and that one of the reverting lines
auto-merged without git flagging a conflict.
Also characterises the kernel gate's one exit-101-with-zero-failures rather
than leaving it as flake: the binary is named (relayflowd-core spec_parity, and
only that one), disk is ruled out at 30 GiB free, one clean reproduction
attempt came back green, and concurrent load is named as the untested
condition. Recorded as unexplained.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): keep timeoutMs deterministic-only through the rebase onto #138
#138 moved `timeoutMs` out of BaseStepSpec/STEP_COMMON_FIELDS into
DeterministicStepSpec/STEP_FIELDS_BY_TYPE.deterministic, and out of
compileStep's shared `base` into the deterministic branch. Both conflicts this
rebase produced landed on that hunk and on the `output` lowering beside it.
compile.ts's deterministic branch now reads the SHARED `verification` binding
and carries #138's timeoutMs spread. The two sides of that conflict are equal
today — typedOutputVerification returns step.verification unchanged for a verb
that cannot declare `output` — so either would have passed every test; the
shared binding is kept because it is what holds the invariant that every branch
of the switch reads the lowered gate, not the raw authored one.
Adapts one assertion in #138's new dependency-validation suite: an exact
toEqual on PreflightResult, which this PR widens with `gates`. A refused spec
compiled nothing, so its gate plan is empty. No assertion weakened, no test
added or removed.
Report updates: the base moved twice and this rebase pinned the SHA; the
verification standard the traps expose — validateSpec, preflight and
`flows check` all answer "was the key accepted?", and only compileYaml +
toKernelSpec answers "did it reach the kernel?", so that path is the primary
assertion and the others corroborate; a four-path timeoutMs proof; and #138's
own blob-comparison method applied to all 15 files it touched, with every
deleted line attributed (two were widenings reading as deletions, the same
false-alarm shape #138's signoff found).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): resolve $ref as a URI, closing the compound-document bypass
Signoff 4 reproduced the original P0 verbatim on the repaired head: one logical
step executed four times, daemon SIGABRT on run.start and every resume, run
stuck "running". The route was a $ref written as a URI naming an $id declared
inside the same document -- the standard 2020-12 compound-schema-document form
that every bundler emits. Both resolvers keyed on a leading "#", so no edge was
added, no cycle was found, and jsonschema then resolved it from the document's
own resource map and overflowed.
The load-bearing error was the comment justifying that: "an unresolvable
reference is left opaque, validator_for refuses it outright". True for remote
resources, false for an in-document $id. The premise held for one case and was
generalised to both.
Reference resolution is now URI-aware and mirrored function for function across
schema.rs and json-schema-bound.ts: collect_scopes builds a resource map keyed
by resolved base URI AND by the raw $id (consistency between registration and
lookup matters more than exact RFC 3986 normalization, and a bundled document
writes the same literal in both places); anchors are keyed (base URI, name)
instead of document-wide first-match-wins, which closes the duplicate-anchor
crash; resolve splits <uri>#<fragment>, resolves the URI part against the base
in effect at that node, and applies the fragment inside that resource. The
checker stays iterative.
Also settles the divergence in the other direction: a RangeError out of Ajv's
compile is caught and discarded rather than reported as "invalid JSON Schema:
Maximum call stack size exceeded". The rule decides legality, the engine
decides only well-formedness, and a stack overflow is neither verdict -- by the
time Ajv runs the bound has already proved the declaration terminates and the
kernel accepts it. Narrow by construction: a schema the bound refuses never
reaches Ajv.
The corpus is extended by derivation from the specification's reference forms
rather than from the file: F1-F12, each with a refused instance and, where the
form can express one, an accepted instance. 12 -> 20 refused, 14 -> 22 accepted.
F12 gets its own engineRefused bucket that pins BOTH halves of the narrowed
premise -- the bound must not claim these, the engine must refuse them -- so a
future engine that accepts an unresolvable reference fails a test instead of
silently reopening the hole.
Also corrects three things signoff 4 caught in the report: a STEP_FIELDS_BY_TYPE
evidence block quoted from the pre-#138 base, an undisclosed fourth test
adaptation of the gates:[] class in cli.test.ts, and the anchor-scoping item in
"what I did not verify" -- which I had guessed would be a false refusal rather
than a crash, and the guess was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): record the rebase onto #151 and the fourth trap
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last
push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's
shape a fourth time in the same function.
#151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec --
authoring keys lowered into the kernel's snake_case dialect, which is authoring
sugar becoming a different object at the boundary, exactly like `output:`. This
branch had changed the same lines from `flow.*` to `compiled.*` for the
snapshot guard. Taking either side wholesale reverts the other; the resolution
is `compiled.triggers.map(toKernelTrigger)`.
The prediction from section 10 held: validateSpec returns ok=true and
`flows check` returns CHECK PASSED exit=0 whether or not the lowering happened.
Only compileYaml + toKernelSpec, read against the kernel object, shows
eventType -> event_type. Unlike the first three traps this one also has
committed fixtures behind it -- #151 pinned a canonical form and a spec hash --
so a reverted lowering would go red in the suite too.
Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned
fixtures, 6 changed by me with every deletion attributed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
---------
Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
* feat(sdk): settle data and code gate contract
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed on invalid gates
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): bundle JSON Schema draft metadata
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed at gate boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(runtime): close gate boundary execution holes
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): reject proxies at exported boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): bound JSON Schema declarations so validation terminates
A `json_schema` gate whose `$ref` graph cycles through only in-place
applicators compiles cleanly and then recurses without bound the first time
it validates an output. In Rust that aborts the process, so `run.start`
accepted the spec, created the journal, ran the step's command, and then took
relayflowd down with SIGABRT — leaving a run stuck `running` that re-executed
its effect on every resume (4 executions of one logical step, no
`completionReason`, no `step.completed`). RFC-0001 covenant 2 and gate 1.
A stack overflow cannot be caught, so the bound is structural and runs before
the declaration is accepted: reject a reference cycle that re-applies to the
same instance and therefore makes no progress. Cycles through a child
applicator (`properties`, `items`, `prefixItems`, ...) consume one level of
the instance per step and stay legal, so ordinary recursive schemas are
unaffected.
`kernel/relayflowd-core/src/schema.rs` and `sdk/src/json-schema-bound.ts`
implement the same rule and are pinned to a shared corpus in
`testdata/json-schema-bound-cases.json`, so the kernel and the SDK agree on
which schemas are legal by construction rather than by coincidence of Ajv's
catchable RangeError and Rust's uncatchable abort. That also closes the
reported SDK/kernel divergence on a self-recursive `$defs`. Every corpus
refusal compiles cleanly in `jsonschema`, which is what makes the tests test
the bound and not the mechanism. `verify` now compiles through the same gate.
Also from the same review:
- `canonicalize`/`specHash` are exported unknown-input helpers, so they carry
the snapshot guard the rest of the exported surface already has, and each
key is read exactly once instead of twice (a demonstrated getter TOCTOU).
- A `json_schema` gate that accepts every output (`{}`, `true`, annotations
only) is still legal, but `flows check` marks the line and preflight emits a
`vacuous_gate` warning: a gate that judges nothing must not read like one
that judges something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): gate output declarations through the same schema bound
Rebase onto main brought in #133's `output:` sugar, which lowers to a
`json_schema` gate at compile time but was only checked with `isObject`. So an
`output` schema the kernel refuses passed `flows check` and was reported as a
gate — an unbounded `$ref` cycle included. Route it through `jsonSchemaError`,
the same gate a hand-written `verification: {type: json_schema}` clears.
Also adapts one test to main's boundary contract rather than deleting it:
`preflight` now returns a named `invalid_spec` refusal where it used to throw,
so the proxy-boundary test accepts either refusal shape and treats "returned a
usable result" as a failure. Trap and getter counters are untouched.
Adds ops/reviews/20260903-pr139-repair-0903.md: the red four-execution ladder,
the structural fix, the shared kernel/SDK corpus, the three-path `output`
proof, the silently-merged-file enumeration, and every gate with literal
output. It also records that origin/main moved from 3da71e2 to 990093b (#136)
mid-work and that this branch is rebased onto 990093b, not the pinned SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): name the second silent-revert trap and characterise the kernel exit-101
Two conflict resolutions on this rebase could have silently reverted freshly
merged behaviour, not one. The brief named step-fields.ts; compile.ts's
compileStep is the sharper one, because neither validateSpec nor `flows check`
can see it — both still report a healthy gate when the `output:` lowering has
been reverted to the raw authored gate. Only compileYaml + toKernelSpec, read
against the kernel verification object, tells "the key was accepted" apart from
"the key became a gate". Records that, and that one of the reverting lines
auto-merged without git flagging a conflict.
Also characterises the kernel gate's one exit-101-with-zero-failures rather
than leaving it as flake: the binary is named (relayflowd-core spec_parity, and
only that one), disk is ruled out at 30 GiB free, one clean reproduction
attempt came back green, and concurrent load is named as the untested
condition. Recorded as unexplained.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): keep timeoutMs deterministic-only through the rebase onto #138
#138 moved `timeoutMs` out of BaseStepSpec/STEP_COMMON_FIELDS into
DeterministicStepSpec/STEP_FIELDS_BY_TYPE.deterministic, and out of
compileStep's shared `base` into the deterministic branch. Both conflicts this
rebase produced landed on that hunk and on the `output` lowering beside it.
compile.ts's deterministic branch now reads the SHARED `verification` binding
and carries #138's timeoutMs spread. The two sides of that conflict are equal
today — typedOutputVerification returns step.verification unchanged for a verb
that cannot declare `output` — so either would have passed every test; the
shared binding is kept because it is what holds the invariant that every branch
of the switch reads the lowered gate, not the raw authored one.
Adapts one assertion in #138's new dependency-validation suite: an exact
toEqual on PreflightResult, which this PR widens with `gates`. A refused spec
compiled nothing, so its gate plan is empty. No assertion weakened, no test
added or removed.
Report updates: the base moved twice and this rebase pinned the SHA; the
verification standard the traps expose — validateSpec, preflight and
`flows check` all answer "was the key accepted?", and only compileYaml +
toKernelSpec answers "did it reach the kernel?", so that path is the primary
assertion and the others corroborate; a four-path timeoutMs proof; and #138's
own blob-comparison method applied to all 15 files it touched, with every
deleted line attributed (two were widenings reading as deletions, the same
false-alarm shape #138's signoff found).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): resolve $ref as a URI, closing the compound-document bypass
Signoff 4 reproduced the original P0 verbatim on the repaired head: one logical
step executed four times, daemon SIGABRT on run.start and every resume, run
stuck "running". The route was a $ref written as a URI naming an $id declared
inside the same document -- the standard 2020-12 compound-schema-document form
that every bundler emits. Both resolvers keyed on a leading "#", so no edge was
added, no cycle was found, and jsonschema then resolved it from the document's
own resource map and overflowed.
The load-bearing error was the comment justifying that: "an unresolvable
reference is left opaque, validator_for refuses it outright". True for remote
resources, false for an in-document $id. The premise held for one case and was
generalised to both.
Reference resolution is now URI-aware and mirrored function for function across
schema.rs and json-schema-bound.ts: collect_scopes builds a resource map keyed
by resolved base URI AND by the raw $id (consistency between registration and
lookup matters more than exact RFC 3986 normalization, and a bundled document
writes the same literal in both places); anchors are keyed (base URI, name)
instead of document-wide first-match-wins, which closes the duplicate-anchor
crash; resolve splits <uri>#<fragment>, resolves the URI part against the base
in effect at that node, and applies the fragment inside that resource. The
checker stays iterative.
Also settles the divergence in the other direction: a RangeError out of Ajv's
compile is caught and discarded rather than reported as "invalid JSON Schema:
Maximum call stack size exceeded". The rule decides legality, the engine
decides only well-formedness, and a stack overflow is neither verdict -- by the
time Ajv runs the bound has already proved the declaration terminates and the
kernel accepts it. Narrow by construction: a schema the bound refuses never
reaches Ajv.
The corpus is extended by derivation from the specification's reference forms
rather than from the file: F1-F12, each with a refused instance and, where the
form can express one, an accepted instance. 12 -> 20 refused, 14 -> 22 accepted.
F12 gets its own engineRefused bucket that pins BOTH halves of the narrowed
premise -- the bound must not claim these, the engine must refuse them -- so a
future engine that accepts an unresolvable reference fails a test instead of
silently reopening the hole.
Also corrects three things signoff 4 caught in the report: a STEP_FIELDS_BY_TYPE
evidence block quoted from the pre-#138 base, an undisclosed fourth test
adaptation of the gates:[] class in cli.test.ts, and the anchor-scoping item in
"what I did not verify" -- which I had guessed would be a false refusal rather
than a crash, and the guess was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): record the rebase onto #151 and the fourth trap
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last
push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's
shape a fourth time in the same function.
#151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec --
authoring keys lowered into the kernel's snake_case dialect, which is authoring
sugar becoming a different object at the boundary, exactly like `output:`. This
branch had changed the same lines from `flow.*` to `compiled.*` for the
snapshot guard. Taking either side wholesale reverts the other; the resolution
is `compiled.triggers.map(toKernelTrigger)`.
The prediction from section 10 held: validateSpec returns ok=true and
`flows check` returns CHECK PASSED exit=0 whether or not the lowering happened.
Only compileYaml + toKernelSpec, read against the kernel object, shows
eventType -> event_type. Unlike the first three traps this one also has
committed fixtures behind it -- #151 pinned a canonical form and a spec hash --
so a reverted lowering would go red in the suite too.
Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned
fixtures, 6 changed by me with every deletion attributed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
---------
Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* refactor(sdk): share the tick bounds checks with a caller that needs them early No behaviour change. The six checks emitDueTicks applied inline move to an exported assertTickScheduleValid, and emitDueTicks calls it -- same checks, same order, same messages, not a second copy. A runner that accepts a schedule from an operator has to refuse a bad grid at declaration rather than at the first poll: connecting, attaching a worker and only then discovering --interval-ms was 1.5 has already told the operator it started. Sharing the function is what keeps the CLI's refusal and the emit path's refusal from drifting -- a bound added here is enforced at both ends by construction. sleepInterruptible is extracted from hn-monitor.ts for the same reason: a source that sleeps on a bare setTimeout observes SIGINT only after the sleep elapses, so a once-a-minute schedule hangs for a minute on every Ctrl-C. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * feat(sdk): flows tick start — a runner that keeps a scheduled relayflow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): make the tick runner's client injection async, and connect before hello The injection surface was synchronous, which hid a missing step. A real JournalClient needs connect() before hello(); the unit tests' fake client has no transport, so it "connected" vacuously and every test passed. The gap only appeared against a live daemon, as: journal client: not connected (hello) Making the seam async and giving the default path ownership of connect+hello means no caller can supply one half of the handshake. A connect failure now reports TICK_RUNNER_FAILED with the error class and message and exits 1, rather than surfacing as an unhandled rejection from inside the poll loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): fail closed on a malformed tick state file `skippedSlots: Array.isArray(x) ? x : []` silently turned a malformed value into "no slots were skipped". That is the exact claim this runner exists to make trustworthy: a state file that cannot say what it missed must stop the runner, not quietly report that it missed nothing. It is also the same silent- fallback shape the runner was written to prevent in emitDueTicks. Refuse a non-array skippedSlots and a non-integer entry within it, matching how loadTickState already refuses a non-integer lastEmittedSlot and a scheduleId that belongs to another schedule. Found by probing my own state loader with hostile-but-valid JSON. The same probe cleared a suspected unbounded-backfill bug: a negative lastEmittedSlot is already caught by the catch-up bound and fails loudly rather than emitting a million ticks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
* refactor(sdk): share the tick bounds checks with a caller that needs them early No behaviour change. The six checks emitDueTicks applied inline move to an exported assertTickScheduleValid, and emitDueTicks calls it -- same checks, same order, same messages, not a second copy. A runner that accepts a schedule from an operator has to refuse a bad grid at declaration rather than at the first poll: connecting, attaching a worker and only then discovering --interval-ms was 1.5 has already told the operator it started. Sharing the function is what keeps the CLI's refusal and the emit path's refusal from drifting -- a bound added here is enforced at both ends by construction. sleepInterruptible is extracted from hn-monitor.ts for the same reason: a source that sleeps on a bare setTimeout observes SIGINT only after the sleep elapses, so a once-a-minute schedule hangs for a minute on every Ctrl-C. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * feat(sdk): flows tick start — a runner that keeps a scheduled relayflow firing #151 shipped the scheduled-trigger primitive; nothing drove it. `agent-relay cloud schedules` reported "No workflow schedules found", the CLI had no reference to tick-source, and `grep -niE "tick|schedule" sdk/src/cli.ts` returned nothing. RFC-0001 names that state: a flow that is never triggered is silently zero. The runner is a sibling of hn-monitor, not a new species: connect journal -> hello -> loop emitDueTicks -> drain on abort -> close. ## The cursor is persisted, and that is the point emitDueTicks starts a FRESH cursor at the current slot, which is right for a schedule's first poll -- a new hourly schedule must not backfill from the epoch. But it means an in-memory-only cursor makes a restart SKIP every slot between shutdown and restart, silently. The dedupe key makes re-delivery harmless, so a lost cursor cannot double-fire; nothing in the primitive protects against the skip. So the runner persists the cursor and reloads it, keeping the distinction: no state on disk means first run, start at the current slot; state behind the grid means catch up and report the arrears. Written temp-then-rename, so a crash mid-write leaves the previous good state. A malformed or foreign-schedule state file is a hard error, not a silent reset -- resetting would convert a corrupted file into a silent skip of everything since the last good emit. ## A skip reaches the operator skippedSlots and TickEmitError exist because a skip that is only a return value is a skip a throw can discard. The runner normalises both shapes into one accounting path, so the success case and the failure case cannot be handled separately and one of them forgotten. Each skipped slot is logged individually with its scheduled instant -- a count lets a reader skim past "3 skipped"; an instant is something an operator can look for and fail to find. ## Bounds, not mechanisms The tests pin the three things that can go wrong, each mutation-verified: restart emits exactly one tick per due slot (M1: ignore the persisted cursor -> 4 tests fail, restart yields [6] instead of [4,5,6]); a slot past maxCatchUp is reported and persisted, including when the same poll then fails; a submit failure leaves the unfired slot due, proven by retrying and seeing 4 and 5 fire while 3 is not re-emitted. A test asserting "the runner fired" would pass under all three mutations. ## One defect found in my own parser parseInt('1.5') is 1, so --interval-ms 1.5 silently became a 1ms schedule. takeNumber now requires an exact integer round-trip, refusing 1.5, 1e3, 0x10, '60000ms' and '' as invocation errors. Bounds themselves are not re-derived here: runTickRunner calls assertTickScheduleValid, the same function emitDueTicks uses, so the CLI's refusal cannot drift from the emit path's. Gates: three tsc configs clean; full SDK suite 461 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; kernel unchanged and green. Known limit: this makes a relayflow schedulable and runnable locally. Whether `agent-relay cloud schedules` needs a separate registration step is not established here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): make the tick runner's client injection async, and connect before hello The injection surface was synchronous, which hid a missing step. A real JournalClient needs connect() before hello(); the unit tests' fake client has no transport, so it "connected" vacuously and every test passed. The gap only appeared against a live daemon, as: journal client: not connected (hello) Making the seam async and giving the default path ownership of connect+hello means no caller can supply one half of the handshake. A connect failure now reports TICK_RUNNER_FAILED with the error class and message and exits 1, rather than surfacing as an unhandled rejection from inside the poll loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): fail closed on a malformed tick state file `skippedSlots: Array.isArray(x) ? x : []` silently turned a malformed value into "no slots were skipped". That is the exact claim this runner exists to make trustworthy: a state file that cannot say what it missed must stop the runner, not quietly report that it missed nothing. It is also the same silent- fallback shape the runner was written to prevent in emitDueTicks. Refuse a non-array skippedSlots and a non-integer entry within it, matching how loadTickState already refuses a non-integer lastEmittedSlot and a scheduleId that belongs to another schedule. Found by probing my own state loader with hostile-but-valid JSON. The same probe cleared a suspected unbounded-backfill bug: a negative lastEmittedSlot is already caught by the catch-up bound and fails loudly rather than emitting a million ticks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev> Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
This PR is two things, and the first is a repair
1. It fixes a P0 on
main: the SDK compiler does not lower trigger keys, so every event-triggered flow is rejected by the kernel.sdk/src/compile.ts:203onorigin/mainis a straight spread with no case conversion:The authoring dialect is camelCase (
hn-monitor.flow.yamldeclareseventType,dedupeKeyTemplate). The kernel'sTriggerSpecis snake_case under#[serde(deny_unknown_fields)]. So the supported SDK authoring path produces a spec the kernel refuses outright:Both shipped gate-2 proactive workloads —
hn-monitoranddir-watcher— were unauthorable through the supported SDK path. RFC-0001's gate 2 "done when" ishn-monitorrunning as a relayflow in production; the path to author it terminated at the kernel.It stayed hidden because the committed
*.spec.canonical.jsonfixtures are snake_case and correct, whilespec-parity.test.tsonly exercised the fourhello-*fixtures, none of which has a trigger. No test ever compiled a triggered flow and compared it to a fixture.The decisive proof the fix is right — the fixtures were correct all along, the compiler was not:
The fixed compiler reproduces the pre-existing snake_case fixtures byte-for-byte.
spec-parity.test.tsnow pins all three (plus the new tick fixture) so it cannot silently regress.This was found only because the brief required generating fixtures through the SDK compiler rather than by hand.
2. It adds the missing primitive: a relayflow can be scheduled.
Deployment limit — read this before merging
No CLI runner ships in this PR.
emitDueTicksis a pure function of(schedule, cursor, now); the timer that calls it is the caller's. Consequentlyagent-relay cloud scheduleswill still report "No workflow schedules found" after this merges. The primitive is proven, not deployed. Do not merge this believing the drive loop is now durable — it is not, yet.The scheduled trigger
A relayflow could not be scheduled.
grep -rniE "cron|schedule|interval|timer"oversdk/src/spec.tsandsdk/src/compile.tsreturned nothing; every shipped trigger was fed by a poller reacting to something external.RFC-0001 had already decided the shape, so this follows the decision rather than making one:
succeededinto a void with no worker enrolled" — a scheduler inside the kernel is exactly what produced that;So a schedule is an event source, not a kernel feature. There is no
cron:field on the spec and no Rust changed.sdk/src/tick-source.tssits besidehn-poller.tsanddir-watcher-poller.ts, submitsflows.tickthrough the sameevent.submitpath, and inherits — rather than reimplements — the kernel's dedupe claim and its liveness sweep.The slot grid
A slot is an interval of the grid, not the moment a poller happened to wake.
scheduledForMsis a pure function of the grid, so one scheduled instant has one identity no matter when — or how many times — a poller notices it.Duplicates and skips are separate mechanisms
Neither covers for the other, and that split is the design.
Duplicates → the dedupe key. Derived from
(schedule_id, scheduled_for_ms)viadedupeKeyTemplate— the grid instant, neveremitted_at_ms(which is carried in the payload for observability and deliberately excluded). The kernel's existing(flow_key, subscription_id, dedupe_key)claim then covers a double-fire, a re-delivery, two racing pollers, and a poller restart with a lost cursor — with no new kernel machinery. Restart-idempotency belongs to the key, not to the cursor.Skips → the cursor.
emitDueTicksemits every slot between the last emitted and now, so a sleeping poller backfills rather than silently dropping slots. The cursor advances only after a successful submit (pollDirectoryOnce'sseendiscipline), so a journal failure mid-backfill leaves the rest due. Slots beyondmaxCatchUpare returned inskippedSlots, not dropped — a week-long outage recovers to the current slot instead of wedging, and the skip cannot be silent.Liveness
The kernel already had the RFC's full trigger-liveness plane (
kernel/relayflowd/src/server/liveness.rs): RelayCron single-winner bucket claim,detect_stale→ journal → latch ordering, CAS-guarded latch,stale_afterreconciliation. What was missing was the authoring half —staleAfterMswas unauthorable through the SDK, so every flow silently inherited the 5-minute engine default, a decision no author made. It is now declarable and bounds-checked against the same i64 limitTriggerSpec::effective_stale_after_msenforces.Observed against a real daemon:
effective_stale_after_ms: 1000is the flow's declared budget, not the 5-minute default — which provesstaleAfterMssurvives the authoring → kernel lowering.The worked example, run for real
testdata/tick-heartbeat.flow.yaml+.spec.canonical.json+.spec.sha256, followinghn-monitoranddir-watcherexactly. Both fixtures generated throughcompileYamlToCanonicalJson/compileAndHash— never by hand.spec_hash=0c3d089f0075c53442c5c2241ada734af5e450a2b558c16030aaf1d1e29127ff.Poller asleep across slots 29400001–29400003, wakes 17s into 29400004; then a second poller re-delivers 29400004 with a lost cursor:
Four missed slots → four distinct successful runs, each reporting its own grid instant and lag. The re-delivery → zero.
Gates
Baseline measured on this worktree before any edit, at
origin/main990093b.RELAYFLOWD_BINpinned for every SDK run to this worktree's own build (~/.relayflows-toolchain/target/173824371/debug/relayflowd), becauselocateRelayflowdotherwise picks the newest daemon by mtime across every worktree's target tree.990093btsc --noEmittsc -p tsconfig.tests.jsonvitest runcargo test --workspacePer-file accounting (set-diff, not counts):
Full-name set-diff: 38 test names added, 0 removed, 0 renamed.
git diff --stat sdk/tests/live-kernel.test.tsis200 ++++with zero deletions — pure insertion, no existing case touched. Kernel:diffof the sortedtest … okline set between baseline and head is empty (identical test set), consistent with no Rust changing.Do not trust green CI here. flows CI runs only
linux-x64-artifactandpacked-consumer— neither the kernel nor the SDK suite. Everything above is local output.Mutation verification
Both mutations: reverted the specific behaviour, ran the specific tests, captured the failure, restored byte-for-byte (
sha256(tick-source.ts)=3edce9029eb6e679ad539b413e2d0251bf3f0cd4a6848b5eea7c00fdc8e87b5ebefore and after both), re-ran, captured the pass.M1 — dedupe key from wall clock instead of the scheduled instant (
scheduled_for_ms: scheduledFor→nowMs; ticks still fire, only the bound is removed):The third line is the one that matters: a real relayflowd spawned a second run for one scheduled instant. Restored →
Tests 4 passed | 43 skipped.M2 — backfill removed (emit only the current slot):
Restored →
Tests 47 passed (47).The tests pin bounds, not mechanisms. Deliberately not written: "a tick fired" — that passes under M1 and would have shipped a schedule that double-runs every slot. What is pinned instead: two ticks for one instant → one run (live kernel); a restart re-emitting a slot → no second run (live kernel); successive slots are not deduped away (the mirror test, without which a constant key would pass everything above and the flow would run once, ever); four missed slots → four distinct runs; a failed submit does not advance the cursor; over-budget slots are reported, exactly once; a tick for a different
schedule_iddoes not wake the flow; the journal carries the declared budget.Honest gaps
subscriptionsrow and noevent_deduperow, so the sweep sees nothing. This is the kernel's own documented "Known gap" (server/liveness.rs), predates this PR, and closing it requires pre-registering spec triggers at spec-observation time — a kernel change, which this PR is scoped out of. A tick source that dies after firing at least once is detected; one that never starts is not.maxCatchUp = 60is not empirically justified — chosen as an hour of one-minute slots.Full design, all literal command output, and the reproduction script:
ops/reviews/20260903-scheduled-trigger-design.md.Review focus
sdk/src/compile.tsis the file at the centre of two silent-revert traps in this lane. The path that distinguishes acceptance from lowering iscompileYaml+toKernelSpec—validateSpechas always accepted the trigger fields;toKernelSpecwas the half that never lowered them.Baseline was measured at
990093b;mainhas since moved to512723c(#138). Not rebased — leaving that call to the lead.Do not merge. An independent signoff is being commissioned at this exact head.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR