Skip to content

fix(sdk): lower trigger keys to the kernel dialect, and add a scheduled trigger - #151

Merged
kjgbot merged 2 commits into
mainfrom
feat/scheduled-trigger-0903
Sep 3, 2026
Merged

fix(sdk): lower trigger keys to the kernel dialect, and add a scheduled trigger#151
kjgbot merged 2 commits into
mainfrom
feat/scheduled-trigger-0903

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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:203 on origin/main is a straight spread with no case conversion:

...(flow.triggers?.length ? { triggers: flow.triggers } : {}),

The authoring dialect is camelCase (hn-monitor.flow.yaml declares eventType, dedupeKeyTemplate). The kernel's TriggerSpec is snake_case under #[serde(deny_unknown_fields)]. So the supported SDK authoring path produces a spec the kernel refuses outright:

$ relayflowd --data-dir $D run $D/sdk-compiled.json
Error: parse run spec /tmp/tickproof.3ndq/sdk-compiled.json

Caused by:
    malformed run spec: unknown field `dedupeKeyTemplate`, expected one of `id`, `executor`, `event_type`, `pattern`, `dedupe_key_template`, `stale_after_ms`

$ relayflowd --data-dir $D2 run testdata/event-triggered-flow.spec.canonical.json
{"run_id":"01M1KJGFDKCYS08H53QQ39Z1E3","status":"parked","completion_reason":null,"completed_steps":0}

Both shipped gate-2 proactive workloads — hn-monitor and dir-watcher — were unauthorable through the supported SDK path. RFC-0001's gate 2 "done when" is hn-monitor running as a relayflow in production; the path to author it terminated at the kernel.

It stayed hidden because the committed *.spec.canonical.json fixtures are snake_case and correct, while spec-parity.test.ts only exercised the four hello-* 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:

event-triggered-flow.yaml MATCHES committed fixture
hn-monitor.flow.yaml      MATCHES committed fixture

The fixed compiler reproduces the pre-existing snake_case fixtures byte-for-byte. spec-parity.test.ts now 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. emitDueTicks is a pure function of (schedule, cursor, now); the timer that calls it is the caller's. Consequently agent-relay cloud schedules will 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" 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 had already decided the shape, so this follows the decision rather than making one:

  • gate 2 "proves: triggers are entry conditions, not schedulers";
  • the first dogfood run, 2026-08-27: "a cron trigger reported succeeded into a void with no worker enrolled" — a scheduler inside the kernel is exactly what produced that;
  • "the trigger plane is liveness-checkedbecause a flow that is never triggered is silently zero — Native's silent-death problem."

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.ts sits beside hn-poller.ts and dir-watcher-poller.ts, submits flows.tick through the same event.submit path, and inherits — rather than reimplements — the kernel's dedupe claim and its liveness sweep.

The slot grid

slot(t)           = floor((t - epochMs) / intervalMs)
scheduledForMs(n) = epochMs + n * intervalMs

A slot is an interval of the grid, not the moment a poller happened to wake. scheduledForMs is 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) via dedupeKeyTemplate — the grid instant, never emitted_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. emitDueTicks emits 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's seen discipline), so a journal failure mid-backfill leaves the rest due. Slots beyond maxCatchUp are returned in skippedSlots, 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_after reconciliation. What was missing was the authoring halfstaleAfterMs was 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 limit TriggerSpec::effective_stale_after_ms enforces.

Observed against a real daemon:

relayflowd: subscription.stale flow="b356ed31...79b" sub="every-minute" event_type="flows.tick" last_event_at_ms=1788437770130 stale_after_ms=1000 detected_at_ms=1788437791447
subscription.registered {"effective_stale_after_ms":1000,"event_type":"flows.tick","executor":"agent-worker","subscription_id":"every-minute"}
subscription.matched    {"event_key":"flows.tick:heartbeat-1m:1764000000000",...}
subscription.stale      {"detected_at_ms":1788437791447,...,"stale_after_ms":1000,"subscription_id":"every-minute"}

effective_stale_after_ms: 1000 is the flow's declared budget, not the 5-minute default — which proves staleAfterMs survives the authoring → kernel lowering.


The worked example, run for real

testdata/tick-heartbeat.flow.yaml + .spec.canonical.json + .spec.sha256, following hn-monitor and dir-watcher exactly. Both fixtures generated through compileYamlToCanonicalJson / 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:

emittedSlots: [29400001,29400002,29400003,29400004]
skippedSlots: []
  slot 29400001 -> matched=true deduped=false run=01M1KK6XV9M5DMJQH3V7HPK8AJ
  slot 29400002 -> matched=true deduped=false run=01M1KK6XVJT7CMM7T5H5SZSXYA
  slot 29400003 -> matched=true deduped=false run=01M1KK6XVR8TTFQ7JH5KKEG7V3
  slot 29400004 -> matched=true deduped=false run=01M1KK6XVYTT6N791BWEX8D1FH
replay of slot 29400004: matched=true deduped=true run=null
run 01M1KK6XV9M5DMJQH3V7HPK8AJ slot 29400001: reason=success output={"lag_ms":197000,"schedule_id":"heartbeat-1m","scheduled_for_ms":1764000060000,"slot":29400001}
run 01M1KK6XVJT7CMM7T5H5SZSXYA slot 29400002: reason=success output={"lag_ms":137000,"schedule_id":"heartbeat-1m","scheduled_for_ms":1764000120000,"slot":29400002}
run 01M1KK6XVR8TTFQ7JH5KKEG7V3 slot 29400003: reason=success output={"lag_ms":77000,"schedule_id":"heartbeat-1m","scheduled_for_ms":1764000180000,"slot":29400003}
run 01M1KK6XVYTT6N791BWEX8D1FH slot 29400004: reason=success output={"lag_ms":17000,"schedule_id":"heartbeat-1m","scheduled_for_ms":1764000240000,"slot":29400004}

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/main 990093b. RELAYFLOWD_BIN pinned for every SDK run to this worktree's own build (~/.relayflows-toolchain/target/173824371/debug/relayflowd), because locateRelayflowd otherwise picks the newest daemon by mtime across every worktree's target tree.

Gate Baseline 990093b Head
tsc --noEmit exit 0 exit 0
tsc -p tsconfig.tests.json exit 0 exit 0
vitest run 370 passed, 3 skipped, 0 failed 408 passed, 3 skipped, 0 failed
cargo test --workspace 100 passed, 0 failed 100 passed, 0 failed

The first baseline attempt reported 12 failures — an artifact of skipping test:prep (no sdk/dist, no kernel build, no chmod on testdata/preflight/*-cli). After the documented prep the baseline is clean, and that clean run is what is compared.

Per-file accounting (set-diff, not counts):

+6  sdk/tests/live-kernel.test.ts  (21 -> 27)
+6  sdk/tests/spec-parity.test.ts  (15 -> 21)
+6  sdk/tests/validate.test.ts     (36 -> 42)
+20 sdk/tests/tick-source.test.ts  (0 -> 20)
    total 373 -> 411

Full-name set-diff: 38 test names added, 0 removed, 0 renamed. git diff --stat sdk/tests/live-kernel.test.ts is 200 ++++ with zero deletions — pure insertion, no existing case touched. Kernel: diff of the sorted test … ok line 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-artifact and packed-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) = 3edce9029eb6e679ad539b413e2d0251bf3f0cd4a6848b5eea7c00fdc8e87b5e before and after both), re-ran, captured the pass.

M1 — dedupe key from wall clock instead of the scheduled instant (scheduled_for_ms: scheduledFornowMs; ticks still fire, only the bound is removed):

× two emissions of one scheduled instant claim the same key, so one run
  → expected 'flows.tick:heartbeat-1m:6000001' to be 'flows.tick:heartbeat-1m:6000000'
× a poller RESTART that loses its cursor re-emits the slot but does not re-run it
  → expected 'flows.tick:heartbeat-1m:6005000' to be 'flows.tick:heartbeat-1m:6000000'
× TWO ticks for ONE scheduled instant produce exactly ONE run
  → the kernel spawned a second run for one scheduled instant: expected { deduped: false, matched: true, …(2) } to match object { matched: true, deduped: true, …(1) }
Tests  3 failed | 1 passed | 43 skipped (47)

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):

× backfills every slot a sleeping poller passed over           → expected [ 104 ] to deeply equal [ 101, 102, 103, 104 ]
× a backfilled tick carries its own lag ...                    → expected [ +0 ] to deeply equal [ 120000, 60000, +0 ]
× does NOT advance past a slot whose submit failed ...         → promise resolved "{ emittedSlots: [ 104 ], …(2) }" instead of rejecting
× reports slots dropped by the catch-up bound ...              → expected [ 110 ] to deeply equal [ 108, 109, 110 ]
× reports each skipped slot exactly once ...                   → expected [] to deeply equal [ 101, 102, 103, 104 ]
× caps at DEFAULT_MAX_CATCH_UP ...                             → expected [ 500 ] to have a length of 60 but got 1
× emits nothing when the current slot has already been emitted → expected [ 100 ] to deeply equal []
× emits nothing when the clock moves backwards ...             → expected [ 90 ] to deeply equal []
× a MISSED interval is backfilled into its own run ...         → expected [ 29400004 ] to deeply equal [ 29400001, 29400002, 29400003, …(1) ]
Tests  9 failed | 38 passed (47)

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_id does not wake the flow; the journal carries the declared budget.


Honest gaps

  1. Provisioned-but-never-fired liveness is still undetected. A schedule whose source never submits a single tick has no subscriptions row and no event_dedupe row, 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.
  2. Nothing restarts a dead schedule. Detection is journaled and logged; the sweep does not re-provision, page, or escalate. The observable hook is left; the action is not built.
  3. No CLI runner ships (see the deployment limit above).
  4. Daemon restart mid-backfill is unverified. The unit test covers a failed submit mid-backfill with a fake sink; I did not kill a live daemon between two slots of one backfill. The dedupe claim is durable in SQLite so I expect it to hold — I did not observe it and am not claiming it.
  5. Cross-host clock skew beyond one interval is unverified. Two pollers on the same grid dedupe correctly (proved), but hosts whose clocks disagree by more than one interval land in different slots and would produce two runs. Real, unaddressed.
  6. maxCatchUp = 60 is 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.ts is the file at the centre of two silent-revert traps in this lane. The path that distinguishes acceptance from lowering is compileYaml + toKernelSpecvalidateSpec has always accepted the trigger fields; toKernelSpec was the half that never lowered them.

Baseline was measured at 990093b; main has since moved to 512723c (#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

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
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit 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.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 8b7d7f0c-5251-4705-b5b3-433de60d7014

📥 Commits

Reviewing files that changed from the base of the PR and between 512723c and ad4b431.

📒 Files selected for processing (14)
  • ops/reviews/20260903-scheduled-trigger-design.md
  • sdk/src/compile.ts
  • sdk/src/index.ts
  • sdk/src/spec.ts
  • sdk/src/tick-source.ts
  • sdk/src/validate.ts
  • sdk/tests/live-kernel.test.ts
  • sdk/tests/spec-parity.test.ts
  • sdk/tests/tick-source.test.ts
  • sdk/tests/validate.test.ts
  • testdata/preflight/tick-slot-report-cli
  • testdata/tick-heartbeat.flow.yaml
  • testdata/tick-heartbeat.spec.canonical.json
  • testdata/tick-heartbeat.spec.sha256

Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands.

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
@kjgbot

kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

P2-1, P2-2, P3 fixed at ad4b431

Signoff was REVIEW_PASSED with no P0/P1; these were fixed at the lead's gate 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". Red captured first for all three, then mutation-verified.

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. Red:

× leaves the skipped slots re-derivable when the FIRST submit fails
  → cursor advanced past slots that never reached the kernel: expected 107 to be 100
× carries the skipped slots on the error when a later submit fails
  → the failing submit did not throw: The instanceof assertion needs a constructor but undefined was given.

The pre-advance is gone and a failure now throws TickEmitError carrying emittedSlots, outcomes, skippedSlots. Three paths account for a skip and none drops it: a submit succeeds → the result carries it; a submit throws → the error carries it; nothing was submitted → the cursor never moved and the next poll re-derives the identical due range. This was flagged as becoming P1 once the CLI runner lands and required as its prerequisite — that prerequisite is now met.

P2-2 — a NaN grid made a schedule permanently, silently zero

epochMs/nowMs were unvalidated while intervalMs was; the asymmetry was the bug. Red (8 cases):

× refuses epochMs = NaN       → promise resolved "{ emittedSlots: [], outcomes: [], …(1) }" instead of rejecting
× refuses epochMs = Infinity  → ... but got 'Invalid array length'
× refuses nowMs = -1          → promise resolved "{ emittedSlots: [ -1 ], …(2) }" instead of rejecting

Both now go through requireNonNegativeInteger; Number.isInteger is false for NaN and both infinities, so one check covers all three.

P3 — the i64 literal admitted exactly the value the kernel refuses

9_223_372_036_854_775_807 rounds up to 2^63 in a double, so value > MAX_STALE_AFTER_MS admitted i64::MAX + 1. Red: rejects staleAfterMs 9223372036854776000 → expected true to be false. 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 took it (2^53 ms ≈ 285,000 years).

Mutation verification

Pre-mutation sha256: tick-source.ts a0f2d1e4874bf164a0cd657028c89636ebf2d4adf4977f55f7441e795b0dd3ad, validate.ts 9986dc54c79476d1355cbfadcf83c6356ffe6d476ca4ef8286c23c6ba6cf4876. Both restored to those exact hashes.

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.

@kjgbot

kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Merging — basis at ad4b4312

Independent signoff REVIEW_PASSED at 969ed44, plus three fixes I required beyond the reviewer's bar. Recording why.

The P0 was confirmed independently, not inherited. The reviewer built the base compiler from a clean git archive 990093b, fed its hn-monitor output to a real daemon, and got malformed run spec: unknown field 'dedupeKeyTemplate'. Both shipped gate-2 workloads were genuinely unauthorable through the supported path. The fixed compiler reproduces all three committed triggered-flow fixtures byte-for-byte and changes none; toKernelTrigger/kernelTriggerToAuthoring are exact inverses over all six fields; unknown fields fail closed in both dialects. Reverting the fix to the raw spread fails 6 tests including all three fixture-parity tests and the spec_hash pin — so the guard is a bound, not a mechanism that merely runs.

Three fixes required after the PASS. The reviewer ranked these P2 on sound reasoning — nothing consumes skippedSlots yet — and also noted the first "becomes P1 the moment the CLI runner lands". Since they must be fixed before the runner regardless, and since both reproduced the exact failure this PR exists to prevent (RFC-0001: "a flow that is never triggered is silently zero"), I held the merge for them:

  • A submit failure in a poll that also skipped slots advanced the cursor past them before the throw discarded the result — 7 due slots lost with no report anywhere, contradicting the module's own guarantee. Now three paths account for a skip and none drops it: success carries it in the result, failure carries it on TickEmitError, and nothing-submitted leaves the cursor unmoved so the next poll re-derives the identical due range. Red reproduced the reviewer's shape verbatim: cursor advanced past slots that never reached the kernel: expected 107 to be 100.
  • NaN/Infinity/negative/non-integer epochMs/nowMs were unvalidated, unlike intervalMs, making a schedule permanently and silently zero. Now guarded by requireNonNegativeInteger; red showed the silent zero verbatim.
  • The staleAfterMs i64 literal rounded up to 2^63, admitting exactly the one value the kernel refuses. Bound is now Number.MAX_SAFE_INTEGER.

Mutation-verified with both files restored to their exact pre-mutation sha256. M3 and M3b fail one test each, and different tests — the two halves of the skip fix are independently load-bearing rather than one mechanism counted twice.

The compiled dialect is byte-identical to the signed-off head: git diff --stat testdata/ is empty and spec_hash is still 0c3d089f…. The delta from 969ed44 is 6 files, +313/−28, confined to the three items plus the report.

Read this before assuming anything changed operationally: no CLI runner ships. agent-relay cloud schedules will still report "No workflow schedules found" after this merges. The primitive is proven, not deployed. This does not make the drive loop durable.

Remaining gaps, carried in the PR body: provisioned-but-never-fired liveness is still undetected (the kernel's own documented gap); nothing restarts a dead schedule; daemon restart mid-backfill and cross-host clock skew were verified by the reviewer and are better than the author claimed — dedupe survives a SIGKILL mid-backfill, and skew shifts when a slot fires, not how many times, because a slot's identity is a property of the grid rather than of either host's clock.

Squash-merging under the checklist: independent signoff PASSED, the three required fixes landed and mutation-verified, CI green at the exact head, base clean, no unresolved feedback, no unpushed WIP.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

@kjgbot
kjgbot merged commit 16860d2 into main Sep 3, 2026
2 checks passed
@kjgbot
kjgbot deleted the feat/scheduled-trigger-0903 branch September 3, 2026 12:57
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
…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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
…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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
…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
kjgbot added a commit that referenced this pull request Sep 4, 2026
* 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>
kjgbot added a commit that referenced this pull request Sep 4, 2026
* 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
kjgbot added a commit that referenced this pull request Sep 4, 2026
* 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>
kjgbot added a commit that referenced this pull request Sep 4, 2026
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant