feat(sdk): compile structured outputs to json_schema - #133
Conversation
Add generic JSON output schemas to llm and agent StepSpec authoring. Compile output declarations into the existing kernel verification.json_schema primitive, reject ambiguous or malformed declarations before submission, and keep the kernel vocabulary unchanged. Pin both sides of persistence: valid agent JSON is journaled as the parsed value, while a schema mismatch journals null plus a failed json_schema verdict. Migrate the hn-monitor authoring YAML to the new sugar without changing its canonical kernel step. Refs #132 Session-Id: 01a06263-743a-7370-bf22-58d2512c5eee
|
Warning Review limit reachedNext included review available in 17 minutes. View limit detailsLimit details: You’ve used the included review currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (3)
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (12)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe SDK adds ChangesStructured output support
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The PR adds validated JSON Schema output declarations while preserving existing verification behavior and reports passing typecheck and test coverage; no actionable merge-blocking risk remains. Poem
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing. Comment |
Session-Id: 01a060b1-3228-74b1-806b-f4a3393d6b37
Rebase integration with origin/main 3da71e2. #133 added the `output` authoring declaration to llm and agent steps; this branch had moved the per-verb allowlist into sdk/src/step-fields.ts, which git merged without a conflict because it is a new file here — so `output` was absent from the live allowlist and the closed verb schema would have refused main's just-merged sugar as an unknown key. `output` is now carried in STEP_FIELDS_BY_TYPE (in the rebased "harden malformed step validation" commit) and the pinning test is updated to match, which also generates the new `deterministic foreign output` cross-verb case. That new case exposed a hole in the generator itself: sample values were read from VERB_FIELD_VALUES by plain index, so a field with no entry produced `{ output: undefined }`. The key is still enumerable, so an in-memory validateSpec refused it, but YAML serialization drops it — meaning the compileYaml and `flows check` halves of every such case asserted against a spec that no longer contained the foreign field. The lookup now throws on a missing sample instead of silently weakening the case. Full SDK suite 370 passed | 3 skipped against a pre-rebase 351 | 3; every delta is accounted for per file in the report. Both P1 mutation verifications re-run post-rebase, restore proven by hash. 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
* feat(sdk): add declared agent model contract Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * docs(review): record PR 136 fresh review Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): make model adapters fail closed Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): bind declared model execution checks Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * docs(review): record PR 136 integration review Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * test(sdk): cover public named-agent boundaries Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): refuse fields outside step verb schemas Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51 Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51 Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): harden malformed step validation Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51 Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): bind named agent preflight provenance Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * docs(review): record PR 136 identity review Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * test(sdk): reproduce static and wrapper identity races Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): seal wrapper execution handshake Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * test(sdk): reproduce wrapper execution boundary gaps Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): harden custom wrapper execution boundary Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): make the wrapper session's bounds reader-owned Repairs the two P1 findings from ops/reviews/20260903-pr136-signoff2-adversarial.md at 3fcf2dc. Both had one shape: the reader handed control of a bound to the thing it was bounding. P1-1 — runWrapperSession never resolved when a wrapper left any descendant holding an inherited stdio pipe. terminate() cleared the only lifecycle timer and left resolution to child.once('close'), which Node emits only after every inherited stream closes. The step neither completed nor failed and no completionReason was ever journaled, and AgentWorker.close() never drained. terminate() now arms a settle timer that calls finish() directly after the SIGKILL, mirroring spawnInvocation in worker-cli.ts. The settled result is identical to the one the 'close' path builds for the same protocolError, so only the timing changes. P1-2 — the 8 KiB handshake bound was measured against the whole accumulated buffer before the loop that drains complete lines and flips the phase, so a conforming wrapper that flushed its execute token and a normal-sized payload in one read was refused as a protocol violation under a 1 MiB maxOutputBytes, nondeterministically, depending on OS pipe coalescing. Complete lines are now drained first; the handshake bound applies only to un-terminated residue while the handshake is still open, and the message names the bound and its value. Neither numeric bound is widened (10s / 8192B / 300s / 1MiB unchanged). P2-1 — docs/SURFACE.md now documents all four wrapper bounds, what each applies to, and the refusal each produces. Tests: 7 added to sdk/tests/worker-cli.test.ts, all red before the fix. Full SDK suite 351 passed | 3 skipped (baseline at 3fcf2dc: 344 | 3). Both fixes mutation-verified; evidence in ops/reviews/20260903-pr136-repair-0903.md. 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 * test(sdk): re-pin the closed verb schema across main's `output` field Rebase integration with origin/main 3da71e2. #133 added the `output` authoring declaration to llm and agent steps; this branch had moved the per-verb allowlist into sdk/src/step-fields.ts, which git merged without a conflict because it is a new file here — so `output` was absent from the live allowlist and the closed verb schema would have refused main's just-merged sugar as an unknown key. `output` is now carried in STEP_FIELDS_BY_TYPE (in the rebased "harden malformed step validation" commit) and the pinning test is updated to match, which also generates the new `deterministic foreign output` cross-verb case. That new case exposed a hole in the generator itself: sample values were read from VERB_FIELD_VALUES by plain index, so a field with no entry produced `{ output: undefined }`. The key is still enumerable, so an in-memory validateSpec refused it, but YAML serialization drops it — meaning the compileYaml and `flows check` halves of every such case asserted against a spec that no longer contained the foreign field. The lookup now throws on a missing sample instead of silently weakening the case. Full SDK suite 370 passed | 3 skipped against a pre-rebase 351 | 3; every delta is accounted for per file in the report. Both P1 mutation verifications re-run post-rebase, restore proven by hash. 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 --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
Rebase reconciliation, kept as its own commit because git could not mark it. #133 added `output` to `STEP_TYPE_KEYS` in `sdk/src/validate.ts`. This branch moved that allowlist into `sdk/src/step-fields.ts`, a file that exists only on this side of the rebase — so it merged with no conflict and did not carry the new field. The conflict git *did* raise was the deletion of the now-dead `STEP_TYPE_KEYS` block, whose obvious resolution silently drops `output` and leaves the validator refusing main's just-merged sugar as an unknown key, on the very PR whose job is refusing unknown keys. `STEP_FIELDS_BY_TYPE` is a plain string table; `satisfies Record<StepType, readonly string[]>` does not tie it to `LlmStepSpec`/`AgentStepSpec`, so neither tsc gate caught the drift. So: - list `output` on `llm` and `agent` in the descriptor, and in the assertion that pins it; - add the generated `deterministic foreign output` refusal case; - assert `output` is accepted on llm/agent through validateSpec, compileYaml and `flows check` — the three paths diverge, and only the first would have caught this; - bind the descriptor to the spec interfaces in `type-tests/step-fields.ts`, both directions, so the same drift is now a compile error rather than a silent runtime refusal. Also closes the generated-case hole the sibling rebase found: reading a missing sample value produced `{ field: undefined }`, which `Object.keys` still reports (so in-memory validation refused as expected) but YAML drops, leaving the compileYaml and `flows check` halves asserting against a spec without the field under test. `foreignFieldValue` now throws instead. 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
`STEP_FIELDS_BY_TYPE` is the only allowlist `validateSpec` consults, but it is a plain string table. `satisfies Record<StepType, readonly string[]>` checks its *shape* — three keys, each an array of strings — and nothing more. It does not check that the table agrees with `DeterministicStepSpec`, `LlmStepSpec` or `AgentStepSpec`. A field added to one of those interfaces but not to the table is therefore refused at runtime as an unknown key, with every compile gate green. That is not hypothetical. It happened twice in one day. #133 added `output` to the interfaces and to the then-current allowlist; when this branch moved the allowlist into `step-fields.ts` the new file merged without a conflict and without `output`, and only a test caught it. #136 then added `agent` to `AgentStepSpec` on the same descriptor. So `type-tests/step-fields.ts` now asserts the correspondence in both directions — every authoring field an interface declares is described, and nothing is described that the interface does not declare. Dropping `output`, `agent` or `timeoutMs` from the table is now a `tsc` error (TS2344) instead of a silent runtime refusal. This is why a type-test file earns its place here: it is the only gate that can see this class of drift, because the drift is between a type and a value and no runtime test exercises the pairing directly. Also asserts the llm/agent `output` sugar end to end. The three paths diverge — `validateSpec` reads the in-memory object, `compileYaml` goes through a YAML round-trip that silently drops an `undefined` value, and `flows check` adds preflight and a process exit code — so each is asserted separately rather than assuming the first covers the others. 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
* fix(sdk): close timeout and dependency boundaries Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(kernel): validate deep dependency graphs iteratively Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * fix(sdk): bound dependency cycle diagnostics Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 * test(sdk): bind the per-verb descriptor to the step interfaces `STEP_FIELDS_BY_TYPE` is the only allowlist `validateSpec` consults, but it is a plain string table. `satisfies Record<StepType, readonly string[]>` checks its *shape* — three keys, each an array of strings — and nothing more. It does not check that the table agrees with `DeterministicStepSpec`, `LlmStepSpec` or `AgentStepSpec`. A field added to one of those interfaces but not to the table is therefore refused at runtime as an unknown key, with every compile gate green. That is not hypothetical. It happened twice in one day. #133 added `output` to the interfaces and to the then-current allowlist; when this branch moved the allowlist into `step-fields.ts` the new file merged without a conflict and without `output`, and only a test caught it. #136 then added `agent` to `AgentStepSpec` on the same descriptor. So `type-tests/step-fields.ts` now asserts the correspondence in both directions — every authoring field an interface declares is described, and nothing is described that the interface does not declare. Dropping `output`, `agent` or `timeoutMs` from the table is now a `tsc` error (TS2344) instead of a silent runtime refusal. This is why a type-test file earns its place here: it is the only gate that can see this class of drift, because the drift is between a type and a value and no runtime test exercises the pairing directly. Also asserts the llm/agent `output` sugar end to end. The three paths diverge — `validateSpec` reads the in-memory object, `compileYaml` goes through a YAML round-trip that silently drops an `undefined` value, and `flows check` adds preflight and a process exit code — so each is asserted separately rather than assuming the first covers the others. 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 * docs(reviews): PR #138 rebase onto 990093b — evidence Replaces the previous rebase report. #136 landed between the two rebases and carries much of what #138 built, so two of the branch's five commits were dropped as empty and the diff against main fell from 8,463 deletions to 68. Records: which commits survived and why; the resolution of every conflict and every audited auto-merge, including the duplicate `foreignFieldValue` the commit-6 auto-merge introduced; three independent proofs that #136 was not reverted; the re-derived hazard enumeration now that the shared files exist on both sides; three-path proofs for `output` and `timeoutMs` across all three verbs; every gate with literal output and per-file count accounting against a measured 990093b baseline; and the mutation verification of the type binding, including the harness fault that first reported two false "guard missed it" results. 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 --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
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
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
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
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
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
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
* feat(sdk): settle data and code gate contract
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed on invalid gates
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): bundle JSON Schema draft metadata
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed at gate boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(runtime): close gate boundary execution holes
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): reject proxies at exported boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): bound JSON Schema declarations so validation terminates
A `json_schema` gate whose `$ref` graph cycles through only in-place
applicators compiles cleanly and then recurses without bound the first time
it validates an output. In Rust that aborts the process, so `run.start`
accepted the spec, created the journal, ran the step's command, and then took
relayflowd down with SIGABRT — leaving a run stuck `running` that re-executed
its effect on every resume (4 executions of one logical step, no
`completionReason`, no `step.completed`). RFC-0001 covenant 2 and gate 1.
A stack overflow cannot be caught, so the bound is structural and runs before
the declaration is accepted: reject a reference cycle that re-applies to the
same instance and therefore makes no progress. Cycles through a child
applicator (`properties`, `items`, `prefixItems`, ...) consume one level of
the instance per step and stay legal, so ordinary recursive schemas are
unaffected.
`kernel/relayflowd-core/src/schema.rs` and `sdk/src/json-schema-bound.ts`
implement the same rule and are pinned to a shared corpus in
`testdata/json-schema-bound-cases.json`, so the kernel and the SDK agree on
which schemas are legal by construction rather than by coincidence of Ajv's
catchable RangeError and Rust's uncatchable abort. That also closes the
reported SDK/kernel divergence on a self-recursive `$defs`. Every corpus
refusal compiles cleanly in `jsonschema`, which is what makes the tests test
the bound and not the mechanism. `verify` now compiles through the same gate.
Also from the same review:
- `canonicalize`/`specHash` are exported unknown-input helpers, so they carry
the snapshot guard the rest of the exported surface already has, and each
key is read exactly once instead of twice (a demonstrated getter TOCTOU).
- A `json_schema` gate that accepts every output (`{}`, `true`, annotations
only) is still legal, but `flows check` marks the line and preflight emits a
`vacuous_gate` warning: a gate that judges nothing must not read like one
that judges something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): gate output declarations through the same schema bound
Rebase onto main brought in #133's `output:` sugar, which lowers to a
`json_schema` gate at compile time but was only checked with `isObject`. So an
`output` schema the kernel refuses passed `flows check` and was reported as a
gate — an unbounded `$ref` cycle included. Route it through `jsonSchemaError`,
the same gate a hand-written `verification: {type: json_schema}` clears.
Also adapts one test to main's boundary contract rather than deleting it:
`preflight` now returns a named `invalid_spec` refusal where it used to throw,
so the proxy-boundary test accepts either refusal shape and treats "returned a
usable result" as a failure. Trap and getter counters are untouched.
Adds ops/reviews/20260903-pr139-repair-0903.md: the red four-execution ladder,
the structural fix, the shared kernel/SDK corpus, the three-path `output`
proof, the silently-merged-file enumeration, and every gate with literal
output. It also records that origin/main moved from 3da71e2 to 990093b (#136)
mid-work and that this branch is rebased onto 990093b, not the pinned SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): name the second silent-revert trap and characterise the kernel exit-101
Two conflict resolutions on this rebase could have silently reverted freshly
merged behaviour, not one. The brief named step-fields.ts; compile.ts's
compileStep is the sharper one, because neither validateSpec nor `flows check`
can see it — both still report a healthy gate when the `output:` lowering has
been reverted to the raw authored gate. Only compileYaml + toKernelSpec, read
against the kernel verification object, tells "the key was accepted" apart from
"the key became a gate". Records that, and that one of the reverting lines
auto-merged without git flagging a conflict.
Also characterises the kernel gate's one exit-101-with-zero-failures rather
than leaving it as flake: the binary is named (relayflowd-core spec_parity, and
only that one), disk is ruled out at 30 GiB free, one clean reproduction
attempt came back green, and concurrent load is named as the untested
condition. Recorded as unexplained.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): keep timeoutMs deterministic-only through the rebase onto #138
#138 moved `timeoutMs` out of BaseStepSpec/STEP_COMMON_FIELDS into
DeterministicStepSpec/STEP_FIELDS_BY_TYPE.deterministic, and out of
compileStep's shared `base` into the deterministic branch. Both conflicts this
rebase produced landed on that hunk and on the `output` lowering beside it.
compile.ts's deterministic branch now reads the SHARED `verification` binding
and carries #138's timeoutMs spread. The two sides of that conflict are equal
today — typedOutputVerification returns step.verification unchanged for a verb
that cannot declare `output` — so either would have passed every test; the
shared binding is kept because it is what holds the invariant that every branch
of the switch reads the lowered gate, not the raw authored one.
Adapts one assertion in #138's new dependency-validation suite: an exact
toEqual on PreflightResult, which this PR widens with `gates`. A refused spec
compiled nothing, so its gate plan is empty. No assertion weakened, no test
added or removed.
Report updates: the base moved twice and this rebase pinned the SHA; the
verification standard the traps expose — validateSpec, preflight and
`flows check` all answer "was the key accepted?", and only compileYaml +
toKernelSpec answers "did it reach the kernel?", so that path is the primary
assertion and the others corroborate; a four-path timeoutMs proof; and #138's
own blob-comparison method applied to all 15 files it touched, with every
deleted line attributed (two were widenings reading as deletions, the same
false-alarm shape #138's signoff found).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): resolve $ref as a URI, closing the compound-document bypass
Signoff 4 reproduced the original P0 verbatim on the repaired head: one logical
step executed four times, daemon SIGABRT on run.start and every resume, run
stuck "running". The route was a $ref written as a URI naming an $id declared
inside the same document -- the standard 2020-12 compound-schema-document form
that every bundler emits. Both resolvers keyed on a leading "#", so no edge was
added, no cycle was found, and jsonschema then resolved it from the document's
own resource map and overflowed.
The load-bearing error was the comment justifying that: "an unresolvable
reference is left opaque, validator_for refuses it outright". True for remote
resources, false for an in-document $id. The premise held for one case and was
generalised to both.
Reference resolution is now URI-aware and mirrored function for function across
schema.rs and json-schema-bound.ts: collect_scopes builds a resource map keyed
by resolved base URI AND by the raw $id (consistency between registration and
lookup matters more than exact RFC 3986 normalization, and a bundled document
writes the same literal in both places); anchors are keyed (base URI, name)
instead of document-wide first-match-wins, which closes the duplicate-anchor
crash; resolve splits <uri>#<fragment>, resolves the URI part against the base
in effect at that node, and applies the fragment inside that resource. The
checker stays iterative.
Also settles the divergence in the other direction: a RangeError out of Ajv's
compile is caught and discarded rather than reported as "invalid JSON Schema:
Maximum call stack size exceeded". The rule decides legality, the engine
decides only well-formedness, and a stack overflow is neither verdict -- by the
time Ajv runs the bound has already proved the declaration terminates and the
kernel accepts it. Narrow by construction: a schema the bound refuses never
reaches Ajv.
The corpus is extended by derivation from the specification's reference forms
rather than from the file: F1-F12, each with a refused instance and, where the
form can express one, an accepted instance. 12 -> 20 refused, 14 -> 22 accepted.
F12 gets its own engineRefused bucket that pins BOTH halves of the narrowed
premise -- the bound must not claim these, the engine must refuse them -- so a
future engine that accepts an unresolvable reference fails a test instead of
silently reopening the hole.
Also corrects three things signoff 4 caught in the report: a STEP_FIELDS_BY_TYPE
evidence block quoted from the pre-#138 base, an undisclosed fourth test
adaptation of the gates:[] class in cli.test.ts, and the anchor-scoping item in
"what I did not verify" -- which I had guessed would be a false refusal rather
than a crash, and the guess was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): record the rebase onto #151 and the fourth trap
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last
push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's
shape a fourth time in the same function.
#151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec --
authoring keys lowered into the kernel's snake_case dialect, which is authoring
sugar becoming a different object at the boundary, exactly like `output:`. This
branch had changed the same lines from `flow.*` to `compiled.*` for the
snapshot guard. Taking either side wholesale reverts the other; the resolution
is `compiled.triggers.map(toKernelTrigger)`.
The prediction from section 10 held: validateSpec returns ok=true and
`flows check` returns CHECK PASSED exit=0 whether or not the lowering happened.
Only compileYaml + toKernelSpec, read against the kernel object, shows
eventType -> event_type. Unlike the first three traps this one also has
committed fixtures behind it -- #151 pinned a canonical form and a spec hash --
so a reverted lowering would go red in the suite too.
Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned
fixtures, 6 changed by me with every deletion attributed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
---------
Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
* feat(sdk): settle data and code gate contract
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed on invalid gates
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): bundle JSON Schema draft metadata
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): fail closed at gate boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(runtime): close gate boundary execution holes
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): reject proxies at exported boundaries
Session-Id: 01a062df-0cdf-7f23-89dd-121aa9ecf743
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): bound JSON Schema declarations so validation terminates
A `json_schema` gate whose `$ref` graph cycles through only in-place
applicators compiles cleanly and then recurses without bound the first time
it validates an output. In Rust that aborts the process, so `run.start`
accepted the spec, created the journal, ran the step's command, and then took
relayflowd down with SIGABRT — leaving a run stuck `running` that re-executed
its effect on every resume (4 executions of one logical step, no
`completionReason`, no `step.completed`). RFC-0001 covenant 2 and gate 1.
A stack overflow cannot be caught, so the bound is structural and runs before
the declaration is accepted: reject a reference cycle that re-applies to the
same instance and therefore makes no progress. Cycles through a child
applicator (`properties`, `items`, `prefixItems`, ...) consume one level of
the instance per step and stay legal, so ordinary recursive schemas are
unaffected.
`kernel/relayflowd-core/src/schema.rs` and `sdk/src/json-schema-bound.ts`
implement the same rule and are pinned to a shared corpus in
`testdata/json-schema-bound-cases.json`, so the kernel and the SDK agree on
which schemas are legal by construction rather than by coincidence of Ajv's
catchable RangeError and Rust's uncatchable abort. That also closes the
reported SDK/kernel divergence on a self-recursive `$defs`. Every corpus
refusal compiles cleanly in `jsonschema`, which is what makes the tests test
the bound and not the mechanism. `verify` now compiles through the same gate.
Also from the same review:
- `canonicalize`/`specHash` are exported unknown-input helpers, so they carry
the snapshot guard the rest of the exported surface already has, and each
key is read exactly once instead of twice (a demonstrated getter TOCTOU).
- A `json_schema` gate that accepts every output (`{}`, `true`, annotations
only) is still legal, but `flows check` marks the line and preflight emits a
`vacuous_gate` warning: a gate that judges nothing must not read like one
that judges something.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): gate output declarations through the same schema bound
Rebase onto main brought in #133's `output:` sugar, which lowers to a
`json_schema` gate at compile time but was only checked with `isObject`. So an
`output` schema the kernel refuses passed `flows check` and was reported as a
gate — an unbounded `$ref` cycle included. Route it through `jsonSchemaError`,
the same gate a hand-written `verification: {type: json_schema}` clears.
Also adapts one test to main's boundary contract rather than deleting it:
`preflight` now returns a named `invalid_spec` refusal where it used to throw,
so the proxy-boundary test accepts either refusal shape and treats "returned a
usable result" as a failure. Trap and getter counters are untouched.
Adds ops/reviews/20260903-pr139-repair-0903.md: the red four-execution ladder,
the structural fix, the shared kernel/SDK corpus, the three-path `output`
proof, the silently-merged-file enumeration, and every gate with literal
output. It also records that origin/main moved from 3da71e2 to 990093b (#136)
mid-work and that this branch is rebased onto 990093b, not the pinned SHA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): name the second silent-revert trap and characterise the kernel exit-101
Two conflict resolutions on this rebase could have silently reverted freshly
merged behaviour, not one. The brief named step-fields.ts; compile.ts's
compileStep is the sharper one, because neither validateSpec nor `flows check`
can see it — both still report a healthy gate when the `output:` lowering has
been reverted to the raw authored gate. Only compileYaml + toKernelSpec, read
against the kernel verification object, tells "the key was accepted" apart from
"the key became a gate". Records that, and that one of the reverting lines
auto-merged without git flagging a conflict.
Also characterises the kernel gate's one exit-101-with-zero-failures rather
than leaving it as flake: the binary is named (relayflowd-core spec_parity, and
only that one), disk is ruled out at 30 GiB free, one clean reproduction
attempt came back green, and concurrent load is named as the untested
condition. Recorded as unexplained.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(sdk): keep timeoutMs deterministic-only through the rebase onto #138
#138 moved `timeoutMs` out of BaseStepSpec/STEP_COMMON_FIELDS into
DeterministicStepSpec/STEP_FIELDS_BY_TYPE.deterministic, and out of
compileStep's shared `base` into the deterministic branch. Both conflicts this
rebase produced landed on that hunk and on the `output` lowering beside it.
compile.ts's deterministic branch now reads the SHARED `verification` binding
and carries #138's timeoutMs spread. The two sides of that conflict are equal
today — typedOutputVerification returns step.verification unchanged for a verb
that cannot declare `output` — so either would have passed every test; the
shared binding is kept because it is what holds the invariant that every branch
of the switch reads the lowered gate, not the raw authored one.
Adapts one assertion in #138's new dependency-validation suite: an exact
toEqual on PreflightResult, which this PR widens with `gates`. A refused spec
compiled nothing, so its gate plan is empty. No assertion weakened, no test
added or removed.
Report updates: the base moved twice and this rebase pinned the SHA; the
verification standard the traps expose — validateSpec, preflight and
`flows check` all answer "was the key accepted?", and only compileYaml +
toKernelSpec answers "did it reach the kernel?", so that path is the primary
assertion and the others corroborate; a four-path timeoutMs proof; and #138's
own blob-comparison method applied to all 15 files it touched, with every
deleted line attributed (two were widenings reading as deletions, the same
false-alarm shape #138's signoff found).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* fix(gates): resolve $ref as a URI, closing the compound-document bypass
Signoff 4 reproduced the original P0 verbatim on the repaired head: one logical
step executed four times, daemon SIGABRT on run.start and every resume, run
stuck "running". The route was a $ref written as a URI naming an $id declared
inside the same document -- the standard 2020-12 compound-schema-document form
that every bundler emits. Both resolvers keyed on a leading "#", so no edge was
added, no cycle was found, and jsonschema then resolved it from the document's
own resource map and overflowed.
The load-bearing error was the comment justifying that: "an unresolvable
reference is left opaque, validator_for refuses it outright". True for remote
resources, false for an in-document $id. The premise held for one case and was
generalised to both.
Reference resolution is now URI-aware and mirrored function for function across
schema.rs and json-schema-bound.ts: collect_scopes builds a resource map keyed
by resolved base URI AND by the raw $id (consistency between registration and
lookup matters more than exact RFC 3986 normalization, and a bundled document
writes the same literal in both places); anchors are keyed (base URI, name)
instead of document-wide first-match-wins, which closes the duplicate-anchor
crash; resolve splits <uri>#<fragment>, resolves the URI part against the base
in effect at that node, and applies the fragment inside that resource. The
checker stays iterative.
Also settles the divergence in the other direction: a RangeError out of Ajv's
compile is caught and discarded rather than reported as "invalid JSON Schema:
Maximum call stack size exceeded". The rule decides legality, the engine
decides only well-formedness, and a stack overflow is neither verdict -- by the
time Ajv runs the bound has already proved the declaration terminates and the
kernel accepts it. Narrow by construction: a schema the bound refuses never
reaches Ajv.
The corpus is extended by derivation from the specification's reference forms
rather than from the file: F1-F12, each with a refused instance and, where the
form can express one, an accepted instance. 12 -> 20 refused, 14 -> 22 accepted.
F12 gets its own engineRefused bucket that pins BOTH halves of the narrowed
premise -- the bound must not claim these, the engine must refuse them -- so a
future engine that accepts an unresolvable reference fails a test instead of
silently reopening the hole.
Also corrects three things signoff 4 caught in the report: a STEP_FIELDS_BY_TYPE
evidence block quoted from the pre-#138 base, an undisclosed fourth test
adaptation of the gates:[] class in cli.test.ts, and the anchor-scoping item in
"what I did not verify" -- which I had guessed would be a false refusal rather
than a crash, and the guess was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
* docs(review): record the rebase onto #151 and the fourth trap
origin/main moved to 16860d2 (#151, trigger-key lowering) right after the last
push. Rebased onto the pinned SHA; two conflicts, and the first is trap 2's
shape a fourth time in the same function.
#151 added `triggers: flow.triggers.map(toKernelTrigger)` to toKernelSpec --
authoring keys lowered into the kernel's snake_case dialect, which is authoring
sugar becoming a different object at the boundary, exactly like `output:`. This
branch had changed the same lines from `flow.*` to `compiled.*` for the
snapshot guard. Taking either side wholesale reverts the other; the resolution
is `compiled.triggers.map(toKernelTrigger)`.
The prediction from section 10 held: validateSpec returns ok=true and
`flows check` returns CHECK PASSED exit=0 whether or not the lowering happened.
Only compileYaml + toKernelSpec, read against the kernel object, shows
eventType -> event_type. Unlike the first three traps this one also has
committed fixtures behind it -- #151 pinned a canonical form and a spec hash --
so a reverted lowering would go red in the suite too.
Blob-compared all 14 files #151 touched: 8 byte-identical including both pinned
fixtures, 6 changed by me with every deletion attributed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
---------
Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Outcome
Adds structured
output:JSON Schema declarations tollmandagentStepSpecs and compiles them onto the existing kernel
verification.json_schemaprimitive. No kernel files or vocabulary changed.output: <JSON Schema>; normalized specscarry the equivalent verification and the kernel boundary never sees a new
field.
output+verificationdeclarationsfail closed, including callers that invoke
toKernelSpecdirectly.output:without changing itscanonical kernel step.
output: nullplus the failed schema verdict.
type. Typed
f.llm/f.agentreturn values remain open in v2 authoring ergonomics: close the gaps found in the research-flow / v1 comparison #132 until theimperative surface has a real consumer.
Refs #132.
Scope boundary
This is issue #132 slice 1 only. It does not publish
@relayflows/surface, implement imperative typed return values, or close #132.v1 explicit
verification:remains supported.Review repairs at
5ab0feeThe fresh review found that the original
expectTypeOfassertion was nevertypechecked and that
OutputFromSchema/TOutputwere speculative public API.This head:
sdk/tsconfig.tests.json, runs it from normalnpm test, and gates itin the exact-head artifact workflow;
toKernelSpeccalls;docs/SURFACE.md.Literal red type-gate evidence
With one deliberately false assertion temporarily added:
The false assertion was removed before commit.
Literal green evidence
Complete SDK/live-kernel run after rebuilding
sdk/dist:Environment note
No kernel source changed. The live run names the prebuilt
relayflowdbinaryused because the host cargo shim remains broken; this is not represented as a
fresh kernel build. The GitHub workflow builds
relayflowdfrom the exact PRhead on Linux before assembling the artifact.