Skip to content

feat(sdk): compile structured outputs to json_schema - #133

Merged
kjgbot merged 4 commits into
mainfrom
feat/v2-typed-outputs
Sep 3, 2026
Merged

kjgbot merged 4 commits into
mainfrom
feat/v2-typed-outputs

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Outcome

Adds structured output: JSON Schema declarations to llm and agent
StepSpecs and compiles them onto the existing kernel
verification.json_schema primitive. No kernel files or vocabulary changed.

  • YAML and object StepSpecs accept output: <JSON Schema>; normalized specs
    carry the equivalent verification and the kernel boundary never sees a new
    field.
  • Non-object schemas and simultaneous output + verification declarations
    fail closed, including callers that invoke toKernelSpec directly.
  • The canonical hn-monitor authoring YAML uses output: without changing its
    canonical kernel step.
  • Live journal assertions pin accepted parsed JSON and rejected output: null
    plus the failed schema verdict.
  • The SDK deliberately does not publish an unchecked phantom parsed-result
    type. Typed f.llm / f.agent return values remain open in v2 authoring ergonomics: close the gaps found in the research-flow / v1 comparison #132 until the
    imperative 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 5ab0fee

The fresh review found that the original expectTypeOf assertion was never
typechecked and that OutputFromSchema / TOutput were speculative public API.
This head:

  • adds sdk/tsconfig.tests.json, runs it from normal npm test, and gates it
    in the exact-head artifact workflow;
  • removes the unused phantom result API;
  • centralizes output validation and pins direct raw toKernelSpec calls;
  • makes the internal normalization path fail closed;
  • documents the lowering contract in docs/SURFACE.md.

Literal red type-gate evidence

With one deliberately false assertion temporarily added:

$ ./node_modules/.bin/tsc -p tsconfig.tests.json
tests/typed-output.test.ts(35,57): error TS2344: Type 'string' does not satisfy the constraint '"Expected: string, Actual: undefined" | "Expected: string, Actual: ..."'.
EXIT=2

The false assertion was removed before commit.

Literal green evidence

$ ./node_modules/.bin/tsc -p tsconfig.tests.json && ./node_modules/.bin/vitest run tests/typed-output.test.ts --reporter=verbose

Test Files  1 passed (1)
     Tests  14 passed (14)
EXIT=0
$ ./node_modules/.bin/tsc && node scripts/make-cli-executable.mjs && ./node_modules/.bin/tsc -p tsconfig.tests.json
[no output]
EXIT=0

Complete SDK/live-kernel run after rebuilding sdk/dist:

$ PATH=/opt/homebrew/Cellar/node/26.7.0/bin:$PATH RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd ./node_modules/.bin/vitest run

stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI
LIVE_ANALYZER ready: claude -p --model claude-haiku-4-5-20251001 round-trip OK

stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once
LIVE_KERNEL kill -9 pid=35187 run=01M1HAP5BE5B53H453ATBWQFR2 while step=two state=Running

Test Files  18 passed (18)
     Tests  251 passed (251)
Duration  57.88s
EXIT=0
$ git diff --check
[no output]
EXIT=0

Environment note

No kernel source changed. The live run names the prebuilt relayflowd binary
used because the host cargo shim remains broken; this is not represented as a
fresh kernel build. The GitHub workflow builds relayflowd from the exact PR
head on Linux before assembling the artifact.

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

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 17 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: ab524ffd-e362-4add-8608-a1d383c2d289

📥 Commits

Reviewing files that changed from the base of the PR and between 5ab0fee and 54361d7.

📒 Files selected for processing (3)
  • ops/reviews/20260902-1620-pr133-history.md
  • ops/reviews/20260902-1620-pr133-structure.md
  • ops/reviews/20260902-1625-pr133-maintainability.md
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: fca379f0-8b50-4f7d-bb52-e006eac41a33

📥 Commits

Reviewing files that changed from the base of the PR and between a0d42ff and 5ab0fee.

📒 Files selected for processing (12)
  • .github/workflows/cloud-runtime-artifact.yml
  • docs/SURFACE.md
  • sdk/package.json
  • sdk/src/compile.ts
  • sdk/src/index.ts
  • sdk/src/output-schema.ts
  • sdk/src/spec.ts
  • sdk/src/validate.ts
  • sdk/tests/live-kernel.test.ts
  • sdk/tests/typed-output.test.ts
  • sdk/tsconfig.tests.json
  • testdata/hn-monitor.flow.yaml

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The SDK adds output JSON Schema declarations for LLM and agent steps. Validation rejects malformed or conflicting declarations. Compilation maps valid declarations to verification.json_schema, and runtime tests cover successful and rejected outputs.

Changes

Structured output support

Layer / File(s) Summary
Authoring contract and validation
sdk/src/output-schema.ts, sdk/src/spec.ts, sdk/src/index.ts, sdk/src/validate.ts
LLM and agent steps accept public output schemas. Validation checks object shape and rejects combined output and verification declarations.
Compilation to kernel verification
sdk/src/compile.ts, sdk/tests/typed-output.test.ts, testdata/hn-monitor.flow.yaml, docs/SURFACE.md
Valid output declarations compile to json_schema verification. Tests cover TypeScript, YAML, public-boundary conversion, conflicts, malformed schemas, and the updated fixture.
Runtime output and rejection behavior
sdk/tests/live-kernel.test.ts
Live-kernel tests verify promoted output after successful validation and null output after retry exhaustion.
SDK build and test validation
sdk/tsconfig.tests.json, sdk/package.json, .github/workflows/cloud-runtime-artifact.yml
The SDK test workflow adds test type-checking. The artifact workflow builds and tests the SDK before building the standalone CLI.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 5ab0f

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

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.


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.

Session-Id: 01a060b1-3228-74b1-806b-f4a3393d6b37
@kjgbot kjgbot changed the title feat(sdk): compile typed llm and agent outputs to json_schema feat(sdk): compile structured outputs to json_schema Sep 2, 2026
@kjgbot
kjgbot merged commit f4ab0c6 into main Sep 3, 2026
2 checks passed
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot added a commit that referenced this pull request Sep 3, 2026
* 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>
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
`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
kjgbot added a commit that referenced this pull request Sep 3, 2026
* 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>
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
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
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.

v2 authoring ergonomics: close the gaps found in the research-flow / v1 comparison

1 participant