Skip to content

feat(sdk): declare agent CLI and model with fail-closed checks - #136

Merged
kjgbot merged 16 commits into
mainfrom
feat/v2-declared-model
Sep 3, 2026
Merged

feat(sdk): declare agent CLI and model with fail-closed checks#136
kjgbot merged 16 commits into
mainfrom
feat/v2-declared-model

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Refs #132.

Outcome

  • Adds the canonical declarative YAML/JSON top-level agents: map with exact { cli, model } declarations and agent-step selection.
  • Lowers selected values into the existing per-step cli / model; validated declarations/selectors remain only through authoring preflight so unused and shadowed models cannot escape registry lint, then disappear in toKernelSpec before the kernel boundary.
  • Pins independent precedence as step override > named declaration > existing flow/project CLI. There is no flow/project model default.
  • Preserves inline/anonymous behavior, v1/default support, and the closed kernel vocabulary.
  • Keeps the exact, case-sensitive nearest-flows.json model registry as offline typo truth; unknown models refuse before any CLI or daemon call.

Typed CLI adapters

flows check and AgentWorker share one typed adapter table:

  • raw claude: capability claude auth status --help, auth claude auth status, exact-model readiness via a real claude -p --model MODEL round trip, execution via claude -p --model MODEL;
  • raw codex: capability codex login status --help, auth codex login status, exact-model readiness via codex exec --model MODEL in an ephemeral read-only session, execution via codex exec --model MODEL;
  • custom wrapper: must return exact token relayflows-agent-cli-v1 from --relayflows-adapter-v1, then uses its explicit auth status / RELAYFLOW_MODEL contract.

A missing command shape or wrapper identification is cli_unsupported, not a false cli_unauthenticated. Failed model readiness followed by successful adapter-specific auth is model_unavailable. Ambient RELAYFLOW_MODEL is always removed; raw providers receive only their model flag, and only identified wrappers receive the private env value.

Fresh-review repair

The exact-head review reports are persisted at:

  • ops/reviews/20260902-1710-pr136-structure.md
  • ops/reviews/20260902-1710-pr136-maintainability.md
  • ops/reviews/20260902-1710-pr136-history.md

Repair commits:

78efc0d docs(review): record PR 136 fresh review
4888d15 fix(sdk): make model adapters fail closed

Literal repair RED

$ ./node_modules/.bin/vitest run tests/cli.test.ts -t 'before compilation erases|raw Claude adapter|Codex login status|nonconforming custom wrapper' --reporter=verbose

× refuses an unknown unused named-agent declaration before compilation erases it
  → expected +0 to be 2
× refuses an unknown shadowed named-agent declaration before compilation erases it
  → expected +0 to be 2
× uses the raw Claude adapter model flag instead of accepting auth status as model proof
  → expected 'auth status|MODEL_ENV=available-model' to contain '-p --model available-model'
× uses Codex login status and reports a rejected model as unavailable, not unauthenticated
  → expected 'REFUSED [cli_unauthenticated] ... auth status' to contain 'REFUSED [model_unavailable]'
× refuses a nonconforming custom wrapper without calling it an authentication failure
  → expected +0 to be 2

Test Files  1 failed (1)
Tests       5 failed | 57 skipped (62)

Literal repair GREEN

Exact erased-declaration reproduction:

$ node --input-type=module -e '<compileSpec + preflight unused/overridden reproduction>'
{"label":"unused","calls":0,"authoringModel":"typo-model","kernelHasAgents":false,"ok":false,"kinds":["model_unknown"]}
{"label":"overridden","calls":0,"authoringModel":"typo-model","kernelHasAgents":false,"ok":false,"kinds":["model_unknown"]}

Focused deterministic suites:

$ ./node_modules/.bin/tsc && node scripts/make-cli-executable.mjs && ./node_modules/.bin/vitest run tests/cli-adapter.test.ts tests/model-selection.test.ts tests/validate.test.ts tests/spec-parity.test.ts tests/preflight.test.ts tests/cli.test.ts tests/bin.test.ts --reporter=dot

Test Files  7 passed (7)
Tests       150 passed (150)
Duration    5.40s

Installed real CLI adapters (explicit opt-in):

$ RELAYFLOWS_REAL_CLI_ADAPTERS=1 ./node_modules/.bin/vitest run tests/real-cli-adapters.test.ts --reporter=verbose --maxWorkers=1 --minWorkers=1

✓ installed raw CLI adapters > round-trips the exact declared Claude model and refuses an impossible one
✓ installed raw CLI adapters > uses Codex login status and classifies an impossible model as unavailable

Test Files  1 passed (1)
Tests       2 passed (2)
Duration    20.94s

Full serial SDK/live-kernel regression suite:

$ RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 ./node_modules/.bin/vitest run --reporter=dot --maxWorkers=1 --minWorkers=1

✓ tests/live-kernel.test.ts (19 tests)
✓ AgentWorker executes the raw claude adapter with its real model flag
✓ AgentWorker executes the raw codex adapter with its real model flag
✓ hn-monitor analyze-story reaches done through the real Claude analyzer CLI
✓ surface resume after a real daemon kill

Test Files  19 passed | 1 skipped (20)
Tests       267 passed | 2 skipped (269)
Duration    72.69s

The two skipped tests are the real-provider cases above, intentionally opt-in and separately green in the preceding command. git diff --cached --check and the final git diff HEAD --check completed with no output.

TypeScript surface dependency

This PR ships the canonical declarative YAML/JSON compiler contract. Matching FlowHeader.agents TypeScript types remain a follow-on after the separately reviewed, currently unmerged @relayflows/surface package in PR #134 lands (or in that repair lane). This PR does not duplicate that package and does not claim issue #132 complete.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 36 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: 155b613d-a356-4d09-8d0a-6ef552fd9741

📥 Commits

Reviewing files that changed from the base of the PR and between 3da71e2 and a86a497.

📒 Files selected for processing (53)
  • docs/SURFACE.md
  • ops/reviews/20260902-1710-pr136-history.md
  • ops/reviews/20260902-1710-pr136-maintainability.md
  • ops/reviews/20260902-1710-pr136-structure.md
  • ops/reviews/20260902-1905-pr136-history.md
  • ops/reviews/20260902-1905-pr136-maintainability.md
  • ops/reviews/20260902-1905-pr136-structure.md
  • ops/reviews/20260902-1945-pr136-repair.md
  • ops/reviews/20260902-1950-pr136-history.md
  • ops/reviews/20260902-1950-pr136-structure.md
  • ops/reviews/20260902-2045-pr136-repair.md
  • ops/reviews/20260902-2048-pr136-history.md
  • ops/reviews/20260902-2048-pr136-maintainability.md
  • ops/reviews/20260902-2048-pr136-structure.md
  • ops/reviews/20260903-pr136-repair-0903.md
  • sdk/src/cli-adapter.ts
  • sdk/src/cli.ts
  • sdk/src/cli/check.ts
  • sdk/src/compile.ts
  • sdk/src/failure-kinds.ts
  • sdk/src/index.ts
  • sdk/src/model-name.ts
  • sdk/src/preflight.ts
  • sdk/src/spec.ts
  • sdk/src/step-dependencies.ts
  • sdk/src/step-fields.ts
  • sdk/src/unknown-keys.ts
  • sdk/src/validate.ts
  • sdk/src/worker-cli.ts
  • sdk/src/worker.ts
  • sdk/src/wrapper-runtime.ts
  • sdk/src/wrapper-session.ts
  • sdk/tests/cli-adapter.test.ts
  • sdk/tests/cli.test.ts
  • sdk/tests/live-kernel.test.ts
  • sdk/tests/model-selection.test.ts
  • sdk/tests/preflight.test.ts
  • sdk/tests/real-cli-adapters.test.ts
  • sdk/tests/verb-field-lint.test.ts
  • sdk/tests/worker-cli.test.ts
  • testdata/flows.json
  • testdata/preflight/analyze-story-claude-cli
  • testdata/preflight/analyze-story-echo-wake-cli
  • testdata/preflight/analyze-story-missing-fields-cli
  • testdata/preflight/analyze-story-stub-cli
  • testdata/preflight/analyze-story-text-only-cli
  • testdata/preflight/authenticated-cli
  • testdata/preflight/counting-cli
  • testdata/preflight/echo-model-cli
  • testdata/preflight/signal-probe-cli
  • testdata/preflight/unauthenticated-cli
  • testdata/preflight/wake-context-probe-cli
  • testdata/preflight/wrapper-session.mjs

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.

@kjgbot

kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

P1: custom wrappers inherit arbitrary ambient secrets — merge blocked pending repair verification

This finding was reproduced against Signoff A exact head 3f129ba1ef955252c1a71efff0473384b6a47370.

sdk/src/worker-cli.ts:34-36 constructed the custom-wrapper environment with { ...process.env }, then deleted only RELAYFLOW_WAKE_CONTEXT and RELAYFLOW_MODEL. The one-process wrapper was spawned with that environment at sdk/src/worker-cli.ts:90-93 before emitting its identity token.

Positive control: I set an unrelated controlled parent sentinel, SIGNOFF_AMBIENT_SECRET=controlled-signoff-sentinel, and invoked the wrapper session. The wrapper recorded the value at identification, before it read stdin:

{"exit_code":0,"stdout_tail":"{\"secretAtIdentify\":\"controlled-signoff-sentinel\",\"instruction\":\"private instruction\",\"model\":\"private-model\",\"wake\":{\"private\":\"wake\"}}","stderr_tail":""}

This is not limited to model/wake context: any ambient credential available to the worker can cross into an untrusted custom wrapper. It violates the requested no-ambient-secret-leakage boundary and RFC-0001 Gate 8 requirement of no ambient environment inheritance.

Required repair: build the custom-wrapper environment from an explicit documented non-secret allowlist, never { ...process.env }; send instruction, model, and wake context only through the identified stdin session.

Required regression test: set an unrelated sentinel secret in the parent environment and prove it is absent both before identity and after execute acknowledgement, while retaining the symlink-retarget test showing private request values do not cross a changed pathname.

This blocks merge until the repair is independently re-reviewed. The PR has now advanced to 3fcf2dc; that new repair commit was not part of this exact-head signoff.

kjgbot added 16 commits September 3, 2026 12:47
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
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
Session-Id: 01a062df-b92c-7a13-815f-147a82a4fc51

Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
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
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
kjgbot force-pushed the feat/v2-declared-model branch from 3fcf2dc to a86a497 Compare September 3, 2026 10:58
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
Records the six exact-head verdicts, the shared failure mode (tests that
prove the mechanism fires rather than that the bound holds, under a CI that
runs neither suite), my wrong design call on #137's canonicalization, the
silent-merge trap that nearly shipped a regression on #136, and the verified
state of cloud #3264 against its owner's DONE claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
@kjgbot

kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

P1 ambient-secret leakage: CLEARED at a86a497b, merge unblocked

Clearing the merge block raised at 3f129ba1 ("custom wrappers inherit arbitrary ambient secrets"). Recording the evidence rather than just lifting it.

The required repair is present. sdk/src/worker-cli.ts:44 now builds the custom-wrapper environment through wrapperEnvironment(process.env), and sdk/src/wrapper-runtime.ts:29-36 is a genuine explicit allowlist — it starts from {} and copies only names in WRAPPER_ENV_ALLOWLIST, rather than spreading process.env and deleting two keys.

The remaining { ...process.env } at worker-cli.ts:49 is on the non-wrapper path — a real installed CLI such as claude or codex. That is documented behaviour at SURFACE.md:43 and unavoidable for a real CLI; it was assessed separately and ranked P3, not P1. The untrusted-wrapper boundary this comment was about is closed.

Independently re-reviewed, as this comment required. Two fresh-context adversarial signoffs, neither by the author:

  • At 3fcf2dcb: five planted parent secrets, and the child environment came back as exactly HOME LANG LOGNAME PATH SHELL TMPDIR USER plus macOS's __CF_USER_TEXT_ENCODING — zero leaked. Identity TOCTOU also held: a self-replacing wrapper was refused and received no instruction.
  • At a86a497b (current head): the closed-environment and identity-TOCTOU cases were re-run as anti-rot checks after the rebase across main's feat(sdk): compile structured outputs to json_schema #133, and both still hold.

That second signoff also found and closed two P1s of its own in the wrapper execution boundary, both since repaired and verified differentially against a materialized pre-fix tree:

  • runWrapperSession never resolved when a wrapper left any stdio pipe open. All 8 leak variants now resolve (~2.3s at an 800ms bound; pre-fix all hung past 15s). The control — SIGTERM ignored but no leaked pipe — resolves at 2007ms on HEAD and 2009ms pre-fix, isolating the leaked pipe as the sole cause. End-to-end through a real AgentWorker at default limits: 11255ms with completionReason: "worker_error" journaled; pre-fix still hung at 60s.
  • A conforming wrapper's first result flush was capped at ~8 KiB nondeterministically and mislabelled a handshake error. Six emission shapes (coalesced, same-tick, delayed-50ms, newline-terminated, execute-token-split, dribbled-64B) x 3 rounds are now byte-identically accepted; pre-fix, exactly the three that ride in one buffer were refused. The handshake bound still holds and is inclusive at 8192, refused at 8193.

Gates at a86a497b: full SDK suite 370 passed / 3 skipped with test:prep and a build (a bare vitest run is not a valid signal here — it skips test:prep, so sdk/dist is missing and prebuilt daemons predate #142); tsc --noEmit and tsc -p tsconfig.tests.json both exit 0; real-CLI adapters 3/3.

Non-blocking items recorded for follow-up, not gating: verb-field-lint.test.ts:312 is named "every declared per-verb field" but omits output on both steps (covered elsewhere and mutation-proven); the ack-phase stall reports "did not identify" when it did; and SURFACE.md:138 understates maxOutputBytes, since pre-token stderr is also counted — the code is safer than the doc.

Merging under the checklist: fresh independent signoff REVIEW_PASSED at the exact head, CI green at that head, base clean, no unpushed WIP.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

@kjgbot
kjgbot merged commit 990093b into main Sep 3, 2026
3 checks passed
@kjgbot
kjgbot deleted the feat/v2-declared-model branch September 3, 2026 11:25
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 pushed a commit that referenced this pull request Sep 3, 2026
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
kjgbot pushed a commit that referenced this pull request Sep 3, 2026
Records #136 merged, the three flows P0s closed structurally, the two
findings turned up incidentally, the rebase hazard generalised across three
PRs, and the fact that a relayflow cannot currently be scheduled at all.

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
The derived-chain gate sampled which failures had already landed. It skipped
every promise that had not settled, so the same program passed or failed on
how many microtask ticks the failure took: ten `await null`s, or any real
derived I/O, cleared the window and a flow recorded terminal success while
work derived from a step had thrown.

Widening the window cannot fix that; there is no safe tick count. What is
timing-independent is whether derived work was still in flight when the body
returned: work the author awaited is settled at that instant in every timing,
and work the author did not await is pending in every timing. The gate now
reads the in-flight set before it awaits anything and refuses on it
(`unsettled_derived_work`). That also makes the settled set complete, so
inspecting settled outcomes stops being a sample and becomes a total answer
over a closed set.

The same escape existed through Promise.allSettled, Promise.any and
Promise.race, which no earlier review had demonstrated. A combinator resolves
its aggregate from inside the reaction of one of its members, so the aggregate
is not downstream of any member by `trigger` — only `Promise.all` was covered,
and only because it is registered by name. Aggregates now inherit attribution
from the context that resolves them, which covers every combinator without
intercepting any of them.

Also repaired, all measured:

- The reachability predicate refused ordinary authoring. `trigger` and
  `resolutionCause` do not connect an async function's resumption context to
  the context it suspended from, so a walk from `done()` reached only the last
  await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of
  steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()`
  is that missing edge, and it is a fact the runtime reports rather than a
  widened approximation.

- The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms,
  30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per
  promise, and only promises created inside the flow's own async scope are
  tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0.
  A performance assertion pins the 30 000 case under 1 000 ms.

- `Promise.all` is still intercepted, because a combinator's aggregate has no
  runtime edge to its non-final members and every alternative reduces to
  callback identity inference. It no longer changes what `Promise.all` does:
  `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)`
  returns a rejected promise instead of throwing synchronously, and `name` is
  `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason
  and its process-wide scope, together with the one documented limit of the
  contract: a derived chain created inside a timer that fires after the body
  returns does not exist yet and cannot be observed.

- `close()` releases every tracked map, not only the promise handles.

Three things a reviewer should not mistake for noise:

- sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in
  sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change.
  Main's new typecheck:tests gate included only src and typed-output.test.ts;
  tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test
  files were type-checked by nothing. Three of the four errors are pre-existing
  at 59c062c. `lib` is raised to ES2024.Promise in that gate config only, for
  Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on
  ES2022.

- Fire-and-forget async work outstanding at done() is now refused even when it
  would have succeeded. That is a deliberate tightening under covenant 2 and is
  documented; awaited work of any shape is unaffected.

- The 140 lines this branch deletes from main are all #134's own intent, and
  the biggest block is regressions/surface.d.ts, whose own header asked to be
  deleted once the real surface shipped. The repair report carries the full
  attribution table, and all 51 of #136's files this branch does not touch are
  blob-identical to 990093b.

The promise-graph observation moves to its own module; the lifecycle keeps the
operation-facing contract. Probes for every claim in the repair report are
committed under ops/probes/pr134-repair-0903/ and run with plain node —
including the kernel-spec assertion that `output` still LOWERS to a
json_schema gate, which validateSpec and `flows check` cannot see.

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
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
Supersedes an unmerged first pass of this report that targeted 990093b. Every
command is pinned to a literal SHA rather than the origin/main ref, which moved
twice during the task.

This rebase produced ZERO conflicts, which is the risk rather than the result:
on #139's rebase a line that reverted a lowering auto-merged silently. Every
hunk was therefore audited by reading.

512723c adds #138, which touches four files this branch also edits (spec.rs,
spec/tests.rs, validate.ts, validate.test.ts) and, critically, moves timeoutMs
to deterministic-only in TWO independent places: the step-fields allowlist and
compileStep's base spread. Getting one right and missing the other yields a spec
that validates but lowers wrong, and validateSpec cannot see it. Both halves are
byte-identical to 512723c and both were re-proved behaviourally through
compileYaml + toKernelSpec: a deterministic step lowers to timeout_ms, llm and
agent are refused at the allowlist. #136's `output` line survives in both verb
lists.

Artifact survival, both directions. All 15 of #138's blobs hashed before and
after: 11 identical including compile.ts and step-fields.ts; the 4 that moved
are the 4 this branch edits and each is a pure addition. Every line of #138
content absent afterwards was enumerated: a first pass with plain diff reported
14, of which 7 were false positives from re-indentation and one rustfmt
attribute rewrap; whitespace-insensitively 7 remain, all attributed and none
authored by #138. In the other direction, a whole-tree set-diff of the branch's
own change set before against after reports exactly three deltas across 41
files, the same three deliberate resolutions as the first pass and nothing else.

The branch's own gate is proved where it lives rather than where it is
convenient: a canonical spec compiled through the SDK, its lowered kernel spec
then mutated and submitted over a real socket with the SDK out of the path. The
kernel refuses all five non-canonical forms across both surface kinds, and
accepts the canonical control.

Gates: tsc --noEmit, tsc -p tsconfig.type-tests.json (a gate #138 added that the
brief's list predates), and tsc -p tsconfig.tests.json all pass; cargo test
--workspace is 130 passed, 0 failed; vitest is 410 passed with one failure, the
pre-existing wire-conformance one. Rust test names set-difference to exactly the
union of both parents, 130 executed against 130 expected.

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
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 added a commit that referenced this pull request Sep 3, 2026
* kernel: dispatch runnable steps in parallel

Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): drive complete parallel dispatch batches

Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): preserve parallel assignment lifecycle

Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): close parallel dispatch admission gaps

Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): reject forged completion pins

Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): canonicalize workspace surfaces across kernel/SDK/socket

Second P1 in PR #137 review: `/mount/repo` and `/mount/./repo` were
admitted concurrently. `parallel::SurfaceIdentity::Opaque("workspace:...")`
compared the raw string; workspace surfaces bypassed the canonical path
identity used for external surfaces, letting alias forms conflict-check
as disjoint.

Repair:

- `SurfaceIdentity::External` → `SurfaceIdentity::Path{kind, namespace,
  components}` with `PathSurfaceKind::{Workspace,External}`. Workspace and
  external surfaces now share the canonical path identity but do not
  cross-collide.
- Rename `spec::external_surface_identity` → `spec::path_surface_identity`;
  callers of the workspace surface use the same canonicalizer.
- SDK `isCanonicalExternalSurface` → `isCanonicalPathSurface`; workspace
  entries now reject empty/./.. components with the same error class as
  external surfaces.
- New `kernel/relayflowd/tests/crash_resume/workspace_identity.rs` covers
  alias refusal and canonical subtree serialization over a real socket.
- 33 new SDK validate cases pin workspace canonicalization; 9 kernel
  parallel_tests cases (ancestor/descendant/sibling for both surface
  kinds) still green.

Evidence:

  $ cargo test -p relayflowd-core --lib machine::parallel
  test result: ok. 9 passed; 0 failed; ...

  $ cargo test -p relayflowd --test crash_resume workspace_aliases
  test result: ok. 1 passed; 0 failed; ... finished in 0.89s

  $ ./node_modules/.bin/vitest run tests/validate.test.ts
  Test Files  1 passed (1)
  Tests  48 passed (48)

Session-Id: 6cae47a0-1263-4c8b-bfaa-bd5ffc72e08e

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): preserve terminal-slash surface compatibility

Session-Id: 01a0667b-bd7e-73c1-8e14-e3e9d13d136e

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(kernel): one spelling per surface, refusing the terminal slash

Reverts 83db98b's accept-and-normalize and restores 53bfee0's strict rule
for BOTH workspace and external surfaces. An independent signoff at 83db98b
found a P0: exactly-once effects can double-fire.

83db98b widened the *external* accept set the same way it widened workspace
-- its own test diff moved "/provider/item/" from reject to accept -- but
added `workspace_surfaces_equal` only. The exactly-once ledger key is a raw
SQL string:

  PRIMARY KEY (step_id, idempotency_key, surface_path)
  relayflowd-journal/src/lib.rs:48, append.rs:162

`idempotency_key = sha256(run_id || step_id)` (machine.rs:396) and `step_id`
are both constant across attempts, so `surface_path` is the only variable in
that key -- and it had two legal spellings. Executed against the real
SqliteJournal at 83db98b:

  attempt1 '/provider/item'   deduped = false
  attempt2 '/provider/item/'  deduped = false
  effect_count = 2 ; confirmed_effect_count = 1

`deduped=false` means "you owe the provider call" (engine/effects.rs:17-24),
so one logical effect fires twice. At the parent commit it failed closed at
effects.rs:127.

Accept-and-normalize only holds if EVERY identity comparison routes through
the same normalization. 83db98b reached fifteen workspace comparison sites
and got all fifteen right; it missed the sixteenth, which happens to be the
one guarding exactly-once. Uniform reject needs no such completeness: one
surface has exactly one spelling, and a non-canonical one never enters the
system.

Two facts make the strict rule the house rule rather than a new constraint:
testdata/hello-agent.flow.yaml already authored `surface: repo`, so the
ladder fixture was the outlier; and 53bfee0's own contract test
`workspace_mounts_and_worktrees_must_have_one_canonical_spelling` already
asserted `/mount/repo/` is refused, which accept-and-normalize contradicted.

RED (before this change, with the tests restored to the strict contract):

  $ cargo test -p relayflowd-core --lib spec::tests::external_surface
  ---- spec::tests::external_surface_paths_must_have_one_canonical_spelling stdout ----
  panicked at relayflowd-core/src/spec/tests.rs:146:9:
  accepted non-canonical surface "/provider/item/"
  test result: FAILED. 0 passed; 1 failed

GREEN:

  $ cargo test --workspace
  22 + 31 + 1 + 1 + 4 + 3 + 37 + 5 + 18 passed; 0 failed

  $ ./node_modules/.bin/vitest run
  259 passed (260); 1 pre-existing live-kernel failure, unchanged

Fixtures: testdata/hello-ladder.flow.yaml authors `surface: repo`, and its
canonical JSON and sha256 are regenerated through the SDK compiler rather
than hand-edited. The canonical diff is one character; the hash moves
ecccd7b2..de095a29 -> 57cac294..f6d57944, and spec_parity confirms kernel and
SDK still agree byte-for-byte.

`workspace_surfaces_equal` is kept across its 18 call sites. Under a single
spelling it is equivalent to string equality for valid surfaces, but it still
compares parsed identities and so fails closed when either side does not
parse -- defense in depth at the pin/declaration seam for exactly the bug
class above. Removing it would be an 18-site change for no safety gain.

A non-canonical spelling is no longer a surface at all, so
`external_surface_contains("/provider/item/", "/provider/item")` now fails
closed rather than resolving to the canonical form, and the two
ancestor/descendant conflict cases that exercised the terminal slash are
dropped as unreachable states rather than restated.

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

* test(sdk): a late completion after cancel reports run_terminal, not lease_conflict

This edits a test that judges this branch's own behaviour, which AGENTS.md
rails against. It is therefore its own commit, touching nothing else, and the
taxonomy call was made by the lead (relayflow-lead-0903), not by this branch.
Flagging it for the independent signoff to re-derive rather than inherit.

The test is #142's own — "cancels over the real socket and rejects the lease
holder after closure", added by main in feat(kernel): add durable run
cancellation. It cancels a run over the socket, then has the lease holder
complete the step, and asserted the refusal carried `lease_conflict`.

What this branch changed is which of two refusals fires first, not whether the
completion is refused. `step.complete` now runs the `ensure_mutable` admission
gate before `completion_worker`:

  ensure_mutable(&engine, &params.run_id)?;              // -> run_terminal
  let worker_id = hub
      .completion_worker(connection_id, &key)
      .map_err(protocol_conflict)?;                      // -> lease_conflict

Each code has exactly one producer in the tree (server/protocol.rs:58 and :46),
so the ordering fully determines which is returned.

Unchanged by this commit, and still asserted by the same test: the completion is
refused, exactly one run.cancel.requested entry exists, and exactly one
run.completed entry exists carrying completionReason "canceled". Only the error
code moved.

The lead's reasoning for preferring run_terminal: lease_conflict tells a worker
"someone else holds your lease", which is false here -- nobody holds it, the run
is over -- and it invites a retry that terminality does not. Checking "can this
run accept mutations at all?" before "who holds this lease?" is also the correct
precedence: the cheaper, more general, fail-closed question first.

The rejected alternative was weakening ensure_mutable so lease_conflict still
won. That trades a correct guard for a stale expectation.

Mutation-verified, both directions, on the rebased tree at 512723c.

RED (before this commit):

  $ ./node_modules/.bin/vitest run
   FAIL  tests/live-kernel.test.ts > ... > cancels over the real socket and rejects the lease holder after closure
  AssertionError: expected JournalProtocolError: run_terminal: run 0... { code: '...' } to match object { code: 'lease_conflict' }
  - Object {
  -   "code": "lease_conflict",
  + JournalProtocolError {
  +   "code": "run_terminal",
        Tests  2 failed | 409 passed | 3 skipped (414)

GREEN (after):

  $ ./node_modules/.bin/vitest run
        Tests  1 failed | 410 passed | 3 skipped (414)

The one remaining failure is the pre-existing `JournalClient wire conformance`
failure, which is independent: it survives moving ensure_mutable after
completion_worker, whereas this test does not.

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): record the PR #137 rebase onto 512723c

Supersedes an unmerged first pass of this report that targeted 990093b. Every
command is pinned to a literal SHA rather than the origin/main ref, which moved
twice during the task.

This rebase produced ZERO conflicts, which is the risk rather than the result:
on #139's rebase a line that reverted a lowering auto-merged silently. Every
hunk was therefore audited by reading.

512723c adds #138, which touches four files this branch also edits (spec.rs,
spec/tests.rs, validate.ts, validate.test.ts) and, critically, moves timeoutMs
to deterministic-only in TWO independent places: the step-fields allowlist and
compileStep's base spread. Getting one right and missing the other yields a spec
that validates but lowers wrong, and validateSpec cannot see it. Both halves are
byte-identical to 512723c and both were re-proved behaviourally through
compileYaml + toKernelSpec: a deterministic step lowers to timeout_ms, llm and
agent are refused at the allowlist. #136's `output` line survives in both verb
lists.

Artifact survival, both directions. All 15 of #138's blobs hashed before and
after: 11 identical including compile.ts and step-fields.ts; the 4 that moved
are the 4 this branch edits and each is a pure addition. Every line of #138
content absent afterwards was enumerated: a first pass with plain diff reported
14, of which 7 were false positives from re-indentation and one rustfmt
attribute rewrap; whitespace-insensitively 7 remain, all attributed and none
authored by #138. In the other direction, a whole-tree set-diff of the branch's
own change set before against after reports exactly three deltas across 41
files, the same three deliberate resolutions as the first pass and nothing else.

The branch's own gate is proved where it lives rather than where it is
convenient: a canonical spec compiled through the SDK, its lowered kernel spec
then mutated and submitted over a real socket with the SDK out of the path. The
kernel refuses all five non-canonical forms across both surface kinds, and
accepts the canonical control.

Gates: tsc --noEmit, tsc -p tsconfig.type-tests.json (a gate #138 added that the
brief's list predates), and tsc -p tsconfig.tests.json all pass; cargo test
--workspace is 130 passed, 0 failed; vitest is 410 passed with one failure, the
pre-existing wire-conformance one. Rust test names set-difference to exactly the
union of both parents, 130 executed against 130 expected.

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): correct the wire-conformance attribution — it is this branch's

An earlier version of this report called the vitest wire-conformance failure
"pre-existing". That was wrong. It was observed at 83db98b, which is #137's own
head and already carries bdd598c; "pre-existing at the branch parent" is not
"pre-existing on main", and I repeated the stronger claim without testing it.

Determined empirically instead. A throwaway worktree at 512723c with its own
kernel build and RELAYFLOWD_BIN pinned to it runs tests/live-kernel.test.ts at
21 passed (21), wire conformance included. The same file fails on this branch.
The failure is the branch's.

It is also not an error-code taxonomy change, which is why no assertion was
touched. bdd598c gates six verbs with ensure_mutable, two of them event.emit and
stream.append. The wire-conformance test starts a deterministic run, which
completes immediately, then calls both on it and asserts they SUCCEED
(matched === 0, offset === 0). Main accepts them; this branch refuses them. The
observable protocol behaviour changed from accepted to refused.

The refusal is load-bearing rather than gratuitous, and the obvious fix is the
wrong one. On main, stream.append against a terminal run writes a
stream.appended entry after run.completed. The branch's state.rs fold guard
rejects exactly that, so a journal main produces is one this branch cannot load:
the same data directory resumes clean under the main binary and fails under the
branch binary with "journal entry 5 appears after terminal run.completed".
Relaxing ensure_mutable to turn the test green would let the daemon write
journals it then cannot resume, which is a durability bug and worse than a red
test.

The rebase also surfaced a direct contradiction: the branch's own
protocol_admission test asserts stream.append and event.emit return run_terminal
and leave the journal unchanged, while main's wire-conformance test asserts they
succeed. Both are in the tree, both were written deliberately, and they cannot
both pass. Because protocol_admission pins the error code, an accept-and-ignore
compromise would require editing that gate too.

Three options are laid out in the report. None is implemented; the branch is
unchanged and the push is on hold pending the lead's call.

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

* test(sdk): a terminal run refuses event.emit and stream.append

PROTOCOL CHANGE. This edits main's test to match a deliberate behaviour change
in this branch, so it is its own commit, touching nothing else. The call was the
lead's (relayflow-lead-0903), not this branch's. The signoff should re-derive it
rather than inherit it.

This is main's test -- "JournalClient wire conformance against live relayflowd".
It started a deterministic run, which completes immediately, then called
event.emit and stream.append on that terminal run and asserted both SUCCEED:

  expect((await client.eventEmit(run_id, 'unmatched', {ok:true})).matched).toBe(0);
  expect((await client.streamAppend(run_id, 'results', {answer:4})).offset).toBe(0);

bdd598c admits every mutating verb through `ensure_mutable`, so this branch
refuses both with run_terminal. Unlike the step.complete change in 269fcc6, this
is not an error-code taxonomy move: observable behaviour on a shipped verb pair
goes from accepted to refused.

It does not remove a working capability. It removes a way to corrupt a journal
that main reports as success. On main, stream.append against a terminal run
journals stream.appended AFTER run.completed, and this branch's state.rs fold
guard rejects exactly that -- so main produces journals the daemon cannot fold
on resume. Same data directory, both binaries:

  MAIN   resume -> exit=0  {"status":"completed","completion_reason":"success"}
  BRANCH resume -> exit=1  Error: fold run journal
                           Caused by: journal entry 5 appears after terminal run.completed

The realistic shape is worse than that synthetic one, and shows main is already
self-inconsistent. A worker holds an llm lease; the run is cancelled out from
under it; the worker then does what a live worker does:

  late step.complete  -> THROW [lease_conflict]      <- main already refuses this
  late stream.append  -> OK    {"offset":0}          <- and corrupts the journal
  late event.emit     -> OK    {"matched":0}
  >>> entries AFTER terminal run.completed: ["stream.appended"]

Main already holds "a terminal run accepts no step completions"
(engine/remote.rs:44, from #142). This branch extends the same rule to the other
mutating verbs, which is what the words already meant.

Checked before changing anything, rather than assuming: no product code calls
either verb (only the JournalClient method definitions); no doc sanctions a
post-terminal append -- kernel/DESIGN.md:384-385 says event.emit "satisfies
wait.event", which a terminal run has none of, and stream.append "journals
stream.appended", which is the corruption; and the one in-repo live use
(crash_resume/llm.rs) is against a parked run and is unaffected.

The test keeps full wire coverage of both verbs. Their success paths move to the
parked llm run, which is the only state in which appending to a run's journal is
meaningful, and the terminal case now asserts the refusal -- matching
crash_resume/protocol_admission.rs, which is left alone because it pins the
invariant correctly.

  $ ./node_modules/.bin/tsc -p tsconfig.tests.json
  (clean)
  $ RELAYFLOWD_BIN=<this worktree's own build> ./node_modules/.bin/vitest run
   Test Files  23 passed | 1 skipped (24)
        Tests  411 passed | 3 skipped (414)

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): record the option-1 resolution and the assumption test behind it

The lead chose option 1 and asked that the assumption behind it be tested rather
than inherited: is there any legitimate late stream.append or event.emit on a
terminal run, out-of-band async completion in particular. Checked four ways and
the assumption holds.

No product code calls either verb — only the JournalClient definitions, a
loopback mock, and tests. No doc sanctions a post-terminal append: DESIGN.md says
event.emit satisfies a wait, which a terminal run has none of, and stream.append
journals stream.appended, which is the corruption itself. The one live in-repo
use appends against a parked run and is unaffected.

The async shape was executed rather than reasoned about, and it cuts against main:
a worker holding an llm lease on a run cancelled out from under it has its late
step.complete refused with lease_conflict while its late stream.append is
accepted in the same moment, journalling after run.completed and producing a
journal the branch cannot fold. Main already holds the rule for step completions
at engine/remote.rs:44; this branch extends it to the remaining mutating verbs.

Adds the PR-body Protocol change section, and records a CI finding: the two
contradicting tests both passed because neither runs in flows CI. cargo test
never runs at all, and CI's vitest names four files, of which live-kernel.test.ts
is not one.

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): lead the Protocol change section with main's self-inconsistency

The strongest argument for the change is not the resume demonstration, it is
that main already holds the rule and fails to apply it uniformly: a worker whose
run was cancelled has its late step.complete refused with lease_conflict and its
late stream.append accepted, in the same moment. This PR extends main's own rule
rather than imposing a new one. Reordered so a reviewer meets that first, with
the resume corruption as the reason it matters and DESIGN.md:384-385 as
corroboration.

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 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 3, 2026
The derived-chain gate sampled which failures had already landed. It skipped
every promise that had not settled, so the same program passed or failed on
how many microtask ticks the failure took: ten `await null`s, or any real
derived I/O, cleared the window and a flow recorded terminal success while
work derived from a step had thrown.

Widening the window cannot fix that; there is no safe tick count. What is
timing-independent is whether derived work was still in flight when the body
returned: work the author awaited is settled at that instant in every timing,
and work the author did not await is pending in every timing. The gate now
reads the in-flight set before it awaits anything and refuses on it
(`unsettled_derived_work`). That also makes the settled set complete, so
inspecting settled outcomes stops being a sample and becomes a total answer
over a closed set.

The same escape existed through Promise.allSettled, Promise.any and
Promise.race, which no earlier review had demonstrated. A combinator resolves
its aggregate from inside the reaction of one of its members, so the aggregate
is not downstream of any member by `trigger` — only `Promise.all` was covered,
and only because it is registered by name. Aggregates now inherit attribution
from the context that resolves them, which covers every combinator without
intercepting any of them.

Also repaired, all measured:

- The reachability predicate refused ordinary authoring. `trigger` and
  `resolutionCause` do not connect an async function's resumption context to
  the context it suspended from, so a walk from `done()` reached only the last
  await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of
  steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()`
  is that missing edge, and it is a fact the runtime reports rather than a
  widened approximation.

- The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms,
  30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per
  promise, and only promises created inside the flow's own async scope are
  tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0.
  A performance assertion pins the 30 000 case under 1 000 ms.

- `Promise.all` is still intercepted, because a combinator's aggregate has no
  runtime edge to its non-final members and every alternative reduces to
  callback identity inference. It no longer changes what `Promise.all` does:
  `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)`
  returns a rejected promise instead of throwing synchronously, and `name` is
  `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason
  and its process-wide scope, together with the one documented limit of the
  contract: a derived chain created inside a timer that fires after the body
  returns does not exist yet and cannot be observed.

- `close()` releases every tracked map, not only the promise handles.

Three things a reviewer should not mistake for noise:

- sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in
  sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change.
  Main's new typecheck:tests gate included only src and typed-output.test.ts;
  tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test
  files were type-checked by nothing. Three of the four errors are pre-existing
  at 59c062c. `lib` is raised to ES2024.Promise in that gate config only, for
  Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on
  ES2022.

- Fire-and-forget async work outstanding at done() is now refused even when it
  would have succeeded. That is a deliberate tightening under covenant 2 and is
  documented; awaited work of any shape is unaffected.

- The 140 lines this branch deletes from main are all #134's own intent, and
  the biggest block is regressions/surface.d.ts, whose own header asked to be
  deleted once the real surface shipped. The repair report carries the full
  attribution table, and all 51 of #136's files this branch does not touch are
  blob-identical to 990093b.

The promise-graph observation moves to its own module; the lifecycle keeps the
operation-facing contract. Probes for every claim in the repair report are
committed under ops/probes/pr134-repair-0903/ and run with plain node —
including the kernel-spec assertion that `output` still LOWERS to a
json_schema gate, which validateSpec and `flows check` cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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
The derived-chain gate sampled which failures had already landed. It skipped
every promise that had not settled, so the same program passed or failed on
how many microtask ticks the failure took: ten `await null`s, or any real
derived I/O, cleared the window and a flow recorded terminal success while
work derived from a step had thrown.

Widening the window cannot fix that; there is no safe tick count. What is
timing-independent is whether derived work was still in flight when the body
returned: work the author awaited is settled at that instant in every timing,
and work the author did not await is pending in every timing. The gate now
reads the in-flight set before it awaits anything and refuses on it
(`unsettled_derived_work`). That also makes the settled set complete, so
inspecting settled outcomes stops being a sample and becomes a total answer
over a closed set.

The same escape existed through Promise.allSettled, Promise.any and
Promise.race, which no earlier review had demonstrated. A combinator resolves
its aggregate from inside the reaction of one of its members, so the aggregate
is not downstream of any member by `trigger` — only `Promise.all` was covered,
and only because it is registered by name. Aggregates now inherit attribution
from the context that resolves them, which covers every combinator without
intercepting any of them.

Also repaired, all measured:

- The reachability predicate refused ordinary authoring. `trigger` and
  `resolutionCause` do not connect an async function's resumption context to
  the context it suspended from, so a walk from `done()` reached only the last
  await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of
  steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()`
  is that missing edge, and it is a fact the runtime reports rather than a
  widened approximation.

- The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms,
  30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per
  promise, and only promises created inside the flow's own async scope are
  tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0.
  A performance assertion pins the 30 000 case under 1 000 ms.

- `Promise.all` is still intercepted, because a combinator's aggregate has no
  runtime edge to its non-final members and every alternative reduces to
  callback identity inference. It no longer changes what `Promise.all` does:
  `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)`
  returns a rejected promise instead of throwing synchronously, and `name` is
  `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason
  and its process-wide scope, together with the one documented limit of the
  contract: a derived chain created inside a timer that fires after the body
  returns does not exist yet and cannot be observed.

- `close()` releases every tracked map, not only the promise handles.

Three things a reviewer should not mistake for noise:

- sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in
  sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change.
  Main's new typecheck:tests gate included only src and typed-output.test.ts;
  tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test
  files were type-checked by nothing. Three of the four errors are pre-existing
  at 59c062c. `lib` is raised to ES2024.Promise in that gate config only, for
  Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on
  ES2022.

- Fire-and-forget async work outstanding at done() is now refused even when it
  would have succeeded. That is a deliberate tightening under covenant 2 and is
  documented; awaited work of any shape is unaffected.

- The 140 lines this branch deletes from main are all #134's own intent, and
  the biggest block is regressions/surface.d.ts, whose own header asked to be
  deleted once the real surface shipped. The repair report carries the full
  attribution table, and all 51 of #136's files this branch does not touch are
  blob-identical to 990093b.

The promise-graph observation moves to its own module; the lifecycle keeps the
operation-facing contract. Probes for every claim in the repair report are
committed under ops/probes/pr134-repair-0903/ and run with plain node —
including the kernel-spec assertion that `output` still LOWERS to a
json_schema gate, which validateSpec and `flows check` cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot pushed a commit that referenced this pull request Sep 4, 2026
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 pushed a commit that referenced this pull request Sep 4, 2026
The derived-chain gate sampled which failures had already landed. It skipped
every promise that had not settled, so the same program passed or failed on
how many microtask ticks the failure took: ten `await null`s, or any real
derived I/O, cleared the window and a flow recorded terminal success while
work derived from a step had thrown.

Widening the window cannot fix that; there is no safe tick count. What is
timing-independent is whether derived work was still in flight when the body
returned: work the author awaited is settled at that instant in every timing,
and work the author did not await is pending in every timing. The gate now
reads the in-flight set before it awaits anything and refuses on it
(`unsettled_derived_work`). That also makes the settled set complete, so
inspecting settled outcomes stops being a sample and becomes a total answer
over a closed set.

The same escape existed through Promise.allSettled, Promise.any and
Promise.race, which no earlier review had demonstrated. A combinator resolves
its aggregate from inside the reaction of one of its members, so the aggregate
is not downstream of any member by `trigger` — only `Promise.all` was covered,
and only because it is registered by name. Aggregates now inherit attribution
from the context that resolves them, which covers every combinator without
intercepting any of them.

Also repaired, all measured:

- The reachability predicate refused ordinary authoring. `trigger` and
  `resolutionCause` do not connect an async function's resumption context to
  the context it suspended from, so a walk from `done()` reached only the last
  await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of
  steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()`
  is that missing edge, and it is a fact the runtime reports rather than a
  widened approximation.

- The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms,
  30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per
  promise, and only promises created inside the flow's own async scope are
  tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0.
  A performance assertion pins the 30 000 case under 1 000 ms.

- `Promise.all` is still intercepted, because a combinator's aggregate has no
  runtime edge to its non-final members and every alternative reduces to
  callback identity inference. It no longer changes what `Promise.all` does:
  `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)`
  returns a rejected promise instead of throwing synchronously, and `name` is
  `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason
  and its process-wide scope, together with the one documented limit of the
  contract: a derived chain created inside a timer that fires after the body
  returns does not exist yet and cannot be observed.

- `close()` releases every tracked map, not only the promise handles.

Three things a reviewer should not mistake for noise:

- sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in
  sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change.
  Main's new typecheck:tests gate included only src and typed-output.test.ts;
  tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test
  files were type-checked by nothing. Three of the four errors are pre-existing
  at 59c062c. `lib` is raised to ES2024.Promise in that gate config only, for
  Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on
  ES2022.

- Fire-and-forget async work outstanding at done() is now refused even when it
  would have succeeded. That is a deliberate tightening under covenant 2 and is
  documented; awaited work of any shape is unaffected.

- The 140 lines this branch deletes from main are all #134's own intent, and
  the biggest block is regressions/surface.d.ts, whose own header asked to be
  deleted once the real surface shipped. The repair report carries the full
  attribution table, and all 51 of #136's files this branch does not touch are
  blob-identical to 990093b.

The promise-graph observation moves to its own module; the lifecycle keeps the
operation-facing contract. Probes for every claim in the repair report are
committed under ops/probes/pr134-repair-0903/ and run with plain node —
including the kernel-spec assertion that `output` still LOWERS to a
json_schema gate, which validateSpec and `flows check` cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot added a commit that referenced this pull request Sep 4, 2026
* feat(surface): ship v2 authoring package

Replace the regression-only ambient declaration with a real @relayflows/surface package and point the dormant in-repo flows at its source contract. Keep execution and compiler concerns behind the journal-backed runtime.

Refs #132

Session-Id: 01a0627a-c11f-7850-9667-c638320d25f4

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): add runtime bridge and package gate

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): lower authored run steps through journal

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): fail closed at authored boundary

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): close authored operation lifecycle

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* test(surface): reproduce authored lifecycle escapes

Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): enforce authored lifecycle provenance

Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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 PR 134 structure findings

Session-Id: 01a062d6-d0fb-7060-b9a7-57031d858ea9

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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): close the derived-work settlement race in the authored gate

The derived-chain gate sampled which failures had already landed. It skipped
every promise that had not settled, so the same program passed or failed on
how many microtask ticks the failure took: ten `await null`s, or any real
derived I/O, cleared the window and a flow recorded terminal success while
work derived from a step had thrown.

Widening the window cannot fix that; there is no safe tick count. What is
timing-independent is whether derived work was still in flight when the body
returned: work the author awaited is settled at that instant in every timing,
and work the author did not await is pending in every timing. The gate now
reads the in-flight set before it awaits anything and refuses on it
(`unsettled_derived_work`). That also makes the settled set complete, so
inspecting settled outcomes stops being a sample and becomes a total answer
over a closed set.

The same escape existed through Promise.allSettled, Promise.any and
Promise.race, which no earlier review had demonstrated. A combinator resolves
its aggregate from inside the reaction of one of its members, so the aggregate
is not downstream of any member by `trigger` — only `Promise.all` was covered,
and only because it is registered by name. Aggregates now inherit attribution
from the context that resolves them, which covers every combinator without
intercepting any of them.

Also repaired, all measured:

- The reachability predicate refused ordinary authoring. `trigger` and
  `resolutionCause` do not connect an async function's resumption context to
  the context it suspended from, so a walk from `done()` reached only the last
  await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of
  steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()`
  is that missing edge, and it is a fact the runtime reports rather than a
  widened approximation.

- The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms,
  30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per
  promise, and only promises created inside the flow's own async scope are
  tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0.
  A performance assertion pins the 30 000 case under 1 000 ms.

- `Promise.all` is still intercepted, because a combinator's aggregate has no
  runtime edge to its non-final members and every alternative reduces to
  callback identity inference. It no longer changes what `Promise.all` does:
  `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)`
  returns a rejected promise instead of throwing synchronously, and `name` is
  `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason
  and its process-wide scope, together with the one documented limit of the
  contract: a derived chain created inside a timer that fires after the body
  returns does not exist yet and cannot be observed.

- `close()` releases every tracked map, not only the promise handles.

Three things a reviewer should not mistake for noise:

- sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in
  sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change.
  Main's new typecheck:tests gate included only src and typed-output.test.ts;
  tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test
  files were type-checked by nothing. Three of the four errors are pre-existing
  at 59c062c. `lib` is raised to ES2024.Promise in that gate config only, for
  Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on
  ES2022.

- Fire-and-forget async work outstanding at done() is now refused even when it
  would have succeeded. That is a deliberate tightening under covenant 2 and is
  documented; awaited work of any shape is unaffected.

- The 140 lines this branch deletes from main are all #134's own intent, and
  the biggest block is regressions/surface.d.ts, whose own header asked to be
  deleted once the real surface shipped. The repair report carries the full
  attribution table, and all 51 of #136's files this branch does not touch are
  blob-identical to 990093b.

The promise-graph observation moves to its own module; the lifecycle keeps the
operation-facing contract. Probes for every claim in the repair report are
committed under ops/probes/pr134-repair-0903/ and run with plain node —
including the kernel-spec assertion that `output` still LOWERS to a
json_schema gate, which validateSpec and `flows check` cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(surface): make aggregate membership exact, not resolution-inferred

The previous revision claimed adoption-from-the-resolving-context "covers
every combinator, present and future". It covers whichever member happens to
resolve the aggregate. An aggregate is derived from EVERY member, but the
runtime supplies an edge to only one: the aggregate is resolved inside the
reaction of whichever member settled last (all, allSettled) or first (race,
any). Inferring membership from that edge is sufficient, never necessary, and
it failed in both directions.

Signoff at 311b18c found both halves from the same line:

  P0  Promise.allSettled([step, unrelated]) where `unrelated` settles last
      orphans the aggregate, so a handled-and-forgotten derived rejection
      escapes and complete-* is lowered with result "success".
  P1  await Promise.allSettled([a, b]) refused every member except the last
      to settle -- authoring docs/SURFACE.md explicitly supports.

Membership cannot be recovered from the promise graph, so it is recorded
where the combinator is called and the member list is in hand: all four
intrinsics are intercepted rather than Promise.all alone.
adoptFromResolvingContext remains as a best-effort fallback for aggregates
built by hand, no longer as the mechanism.

Why the tests could not have caught it: every combinator row used a
single-member aggregate, `Promise.allSettled([step])`, which is always
resolved by the step itself. That shape cannot exhibit "resolved by a
different member" BY CONSTRUCTION -- the rows would have passed however the
mechanism was written. They now use multi-member aggregates that vary the
resolver, in both directions, across all four combinators.

MUTATION-VERIFIED. Reverting COMBINATORS to ['all'] (the pre-fix state) fails
exactly six tests, and they are both halves of the defect:

  x allSettled resolved by an unrelated member  -> promise resolved instead of rejecting
  x allSettled with the step declared second    -> promise resolved instead of rejecting
  x race resolved by an unrelated member        -> promise resolved instead of rejecting
  x any resolved by an unrelated member         -> promise resolved instead of rejecting
  x await Promise.allSettled over two steps     -> unawaited_step: run-1
  x await Promise.allSettled over five steps    -> unawaited_step: run-1..run-4

  pre  sha256 081787dc17727e379bfb790ca747ac33bc0be0ab627f0fb01573e3d203c2c201
  post sha256 edde33ae3689b49a7f61432003c1add70629c54757e72ce1464d0635969b641b  (file changed: asserted before the run)
  restored     081787dc17727e379bfb790ca747ac33bc0be0ab627f0fb01573e3d203c2c201  (byte-for-byte)

Spec transparency preserved and widened to all four: Symbol.iterator read
exactly once as the intrinsic does, a non-iterable handed to the intrinsic so
it produces the specified rejected promise rather than resolving [] or
throwing synchronously, `this` honoured for subclasses, name/length matching.

Gates: tsc --noEmit 0; tsc -p tsconfig.tests.json 0; full SDK suite
432 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this
worktree's build; lifecycle executor suite 27/27.

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

---------

Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
kjgbot added a commit that referenced this pull request Sep 4, 2026
* feat(surface): ship v2 authoring package

Replace the regression-only ambient declaration with a real @relayflows/surface package and point the dormant in-repo flows at its source contract. Keep execution and compiler concerns behind the journal-backed runtime.

Refs #132

Session-Id: 01a0627a-c11f-7850-9667-c638320d25f4

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): add runtime bridge and package gate

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): lower authored run steps through journal

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): fail closed at authored boundary

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): close authored operation lifecycle

Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* test(surface): reproduce authored lifecycle escapes

Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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(surface): enforce authored lifecycle provenance

Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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 PR 134 structure findings

Session-Id: 01a062d6-d0fb-7060-b9a7-57031d858ea9

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-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): close the derived-work settlement race in the authored gate

The derived-chain gate sampled which failures had already landed. It skipped
every promise that had not settled, so the same program passed or failed on
how many microtask ticks the failure took: ten `await null`s, or any real
derived I/O, cleared the window and a flow recorded terminal success while
work derived from a step had thrown.

Widening the window cannot fix that; there is no safe tick count. What is
timing-independent is whether derived work was still in flight when the body
returned: work the author awaited is settled at that instant in every timing,
and work the author did not await is pending in every timing. The gate now
reads the in-flight set before it awaits anything and refuses on it
(`unsettled_derived_work`). That also makes the settled set complete, so
inspecting settled outcomes stops being a sample and becomes a total answer
over a closed set.

The same escape existed through Promise.allSettled, Promise.any and
Promise.race, which no earlier review had demonstrated. A combinator resolves
its aggregate from inside the reaction of one of its members, so the aggregate
is not downstream of any member by `trigger` — only `Promise.all` was covered,
and only because it is registered by name. Aggregates now inherit attribution
from the context that resolves them, which covers every combinator without
intercepting any of them.

Also repaired, all measured:

- The reachability predicate refused ordinary authoring. `trigger` and
  `resolutionCause` do not connect an async function's resumption context to
  the context it suspended from, so a walk from `done()` reached only the last
  await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of
  steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()`
  is that missing edge, and it is a fact the runtime reports rather than a
  widened approximation.

- The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms,
  30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per
  promise, and only promises created inside the flow's own async scope are
  tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0.
  A performance assertion pins the 30 000 case under 1 000 ms.

- `Promise.all` is still intercepted, because a combinator's aggregate has no
  runtime edge to its non-final members and every alternative reduces to
  callback identity inference. It no longer changes what `Promise.all` does:
  `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)`
  returns a rejected promise instead of throwing synchronously, and `name` is
  `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason
  and its process-wide scope, together with the one documented limit of the
  contract: a derived chain created inside a timer that fires after the body
  returns does not exist yet and cannot be observed.

- `close()` releases every tracked map, not only the promise handles.

Three things a reviewer should not mistake for noise:

- sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in
  sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change.
  Main's new typecheck:tests gate included only src and typed-output.test.ts;
  tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test
  files were type-checked by nothing. Three of the four errors are pre-existing
  at 59c062c. `lib` is raised to ES2024.Promise in that gate config only, for
  Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on
  ES2022.

- Fire-and-forget async work outstanding at done() is now refused even when it
  would have succeeded. That is a deliberate tightening under covenant 2 and is
  documented; awaited work of any shape is unaffected.

- The 140 lines this branch deletes from main are all #134's own intent, and
  the biggest block is regressions/surface.d.ts, whose own header asked to be
  deleted once the real surface shipped. The repair report carries the full
  attribution table, and all 51 of #136's files this branch does not touch are
  blob-identical to 990093b.

The promise-graph observation moves to its own module; the lifecycle keeps the
operation-facing contract. Probes for every claim in the repair report are
committed under ops/probes/pr134-repair-0903/ and run with plain node —
including the kernel-spec assertion that `output` still LOWERS to a
json_schema gate, which validateSpec and `flows check` cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

* fix(surface): make aggregate membership exact, not resolution-inferred

The previous revision claimed adoption-from-the-resolving-context "covers
every combinator, present and future". It covers whichever member happens to
resolve the aggregate. An aggregate is derived from EVERY member, but the
runtime supplies an edge to only one: the aggregate is resolved inside the
reaction of whichever member settled last (all, allSettled) or first (race,
any). Inferring membership from that edge is sufficient, never necessary, and
it failed in both directions.

Signoff at 311b18c found both halves from the same line:

  P0  Promise.allSettled([step, unrelated]) where `unrelated` settles last
      orphans the aggregate, so a handled-and-forgotten derived rejection
      escapes and complete-* is lowered with result "success".
  P1  await Promise.allSettled([a, b]) refused every member except the last
      to settle -- authoring docs/SURFACE.md explicitly supports.

Membership cannot be recovered from the promise graph, so it is recorded
where the combinator is called and the member list is in hand: all four
intrinsics are intercepted rather than Promise.all alone.
adoptFromResolvingContext remains as a best-effort fallback for aggregates
built by hand, no longer as the mechanism.

Why the tests could not have caught it: every combinator row used a
single-member aggregate, `Promise.allSettled([step])`, which is always
resolved by the step itself. That shape cannot exhibit "resolved by a
different member" BY CONSTRUCTION -- the rows would have passed however the
mechanism was written. They now use multi-member aggregates that vary the
resolver, in both directions, across all four combinators.

MUTATION-VERIFIED. Reverting COMBINATORS to ['all'] (the pre-fix state) fails
exactly six tests, and they are both halves of the defect:

  x allSettled resolved by an unrelated member  -> promise resolved instead of rejecting
  x allSettled with the step declared second    -> promise resolved instead of rejecting
  x race resolved by an unrelated member        -> promise resolved instead of rejecting
  x any resolved by an unrelated member         -> promise resolved instead of rejecting
  x await Promise.allSettled over two steps     -> unawaited_step: run-1
  x await Promise.allSettled over five steps    -> unawaited_step: run-1..run-4

  pre  sha256 081787dc17727e379bfb790ca747ac33bc0be0ab627f0fb01573e3d203c2c201
  post sha256 edde33ae3689b49a7f61432003c1add70629c54757e72ce1464d0635969b641b  (file changed: asserted before the run)
  restored     081787dc17727e379bfb790ca747ac33bc0be0ab627f0fb01573e3d203c2c201  (byte-for-byte)

Spec transparency preserved and widened to all four: Symbol.iterator read
exactly once as the intrinsic does, a non-iterable handed to the intrinsic so
it produces the specified rejected promise rather than resolving [] or
throwing synchronously, `this` honoured for subclasses, name/length matching.

Gates: tsc --noEmit 0; tsc -p tsconfig.tests.json 0; full SDK suite
432 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this
worktree's build; lifecycle executor suite 27/27.

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

---------

Co-authored-by: kjgbot <kjgbot@agentrelay.dev>
Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot added a commit that referenced this pull request Sep 4, 2026
* 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.

1 participant