✨ Run a workflow on a runner and its owner somewhere else (#698) - #763
Open
minkimcello wants to merge 81 commits into
Conversation
Turn PR taras#630's draft into a decision-complete first-release architecture. No product, deployment, authority, identity, ownership, failure, replay, public form, permission or ordering decision is left for a later Implementor. The factory specification now settles: the canonical `github-issue-v1` run-ID derivation and unsupported issue-transfer drift; `User` as the only Stage 1 spelling; the deployment topology of one SQLite-backed Cloudflare Durable Object, an authenticated executor WebSocket, an ephemeral Actions runner and a dedicated GitHub App; authenticated ingress order, bounded intake, dispatch payload and OIDC claims; the exact App permissions, host ceilings, human permission floor and `.github/workflows/**` denial; suspend-on-every-conflict as the single Stage 4 profile; the Stage 7 trusted merge, `Git.PublishTarget` compare-and-swap and remote-effect-before-terminal ordering; and the exact public contract inventory. Section 8's remaining material decisions are gone. architecture.md gains the terminology, the remote storage and executor topology, the delivery-plane generalization, the Project-provider boundary, comments/readiness/closure, ordered merge, target publication, trusted evidence execution, terminal settlement, the split trusted host, and ten construct inventory rows marked "specified by taras#710; implementation unbuilt". The workflow and Workspace specifications gain the remote host, the executor connection, which requests need it and which do not, the host-derived public run ID, and provider-neutral contracts for `Git.Merge`, `Git.PublishTarget`, `PullRequest.Comment`/`Ready`/`Close`, `Issue.Comment`/`Close`, `Evidence.Run` and `Project.Status` — none of them reachable by an Agent or generated XMD, whose write table stays exactly `File:write`, `Dir` and `File.Delete`. The executable-MDX specification gains the exact authored forms and the frozen WRH/WGI/WGE/WGM/WER/WFP/WFL acceptance tiers. Documentation only: no production code, workflow YAML, dependency, generated artifact or executable fixture changes, and the ownership SVG already agrees with the amended text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Answer the Architect's three blocking findings on 86a4694. The public contracts are now closed shapes rather than descriptions of shapes, run identity has one spelling, and the two terminal paths are separate everywhere. Finding 1 — exact bindings and durable records. specs/workflow-workspace-spec.md is the normative schema location for every authored construct: §7.8 defines the `GitMergeResult` clean/conflicted union, the conflict-entry record, its sorting, duplicate and missing-side rules, and restoration failure; §7.9 defines the `Git.PublishTarget` request and result with five exhaustive pre-states; §7.10 defines the comment request/result and how an engine-derived effect identity is made remotely observable without using body text, plus literal `Ready`/`Close` bindings; §10.3 does the same for `Issue.Comment` and `Issue.Close`; §10.5 settles `Evidence.Run` — the whole list runs, separate bounded stdout and stderr with stated truncation, and an explicit table of which cases bind, which fail and which commit nothing, with cancellation and teardown precedence fixed; §10.6 defines `Project.Status`. The factory specification §11.2 defines the versioned closed factory protocol schemas — subject, stage, revision, handoff, actor, outcome, invalidation, verdicts, conflict suspension, Stage 7 decisions, the frontier and its complete reduction table, and the two asymmetric terminals. Finding 1 also adds `PullRequest.Merged` (§7.11), the reconciled Git-host observation that owns the post-publication merged fact, and settles the remote host boundary (§13.2): the existing four-method `WorkflowHost` stays the host assembly contract with a Cloudflare implementation beside the Deno one, the runner-to-owner transport is a closed versioned envelope that refuses rather than adapts, and an ownership table says which side owns each concern. Finding 2 — every restatement of the run-ID derivation now uses `canonical GitHub authority` byte for byte; the undefined Issue-provider spelling is gone. Finding 3 — architecture.md and the factory specification now state the merged and abandoned paths as separate ordered step lists, and the terminal record's two shapes differ exactly as the paths do, so neither can be read as requiring the other's effects. Inventories, evidence tiers and the acceptance checklist are reconciled with all of it. Documentation only; the ownership SVG is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Apply rulings A-K from the Architect's answers on 80884f9. A. Keep the four-method `WorkflowHost` and add that its transition and request types — `WorkflowExecutionTransitions`, `WorkflowBeginRequest`, `WorkflowExecutionBegun`, `WorkflowForkRequest`, `WorkflowForkSelection`, `WorkflowRunCreation` — are provider-neutral and become package-root public types, with runtime-named entrypoints keeping implementations and retained encodings. Recorded as authorized future export work, not performed here. B. `Evidence.Run` becomes an ordered fail-fast pipeline binding the executed prefix: `{ completion, authoredCommands, executed, runTimeout? }`, rows carrying `limit` for whichever of the two host-owned ceilings fired, a `runTimeout` record for a whole-run expiry between commands rather than a fabricated argv row, timeouts as ordinary unsuccessful outcomes, and fixed precedence — cancellation over everything, first infrastructure failure authoritative with a later teardown failure as secondary evidence, teardown authoritative alone — with bounded diagnostic evidence retained on a failed effect's Error. C. A comment provider must support a stable opaque correlation marker or refuse before its first mutation. The authored logical body is preserved byte for byte and the correlation representation rides outside it. Absence is now judged against attempt state: no marker after an attempted-but-uncommitted creation is permanent ambiguity, not proven absence. D. `PullRequest.Merged` splits still-open (temporary unavailability) from closed-unmerged (conflict), and the factory gains §10.4 — a bounded host-configured retry, then a durable machine wait with its own protocol record, woken by intake or an explicit resume and carrying no verdict. E. A configured total bijection between the nine stages and nine exact Project status option IDs, validated against a complete reread and refusing before any intake, token, run or projection. F. `Git.Merge`'s `purpose` is validated authorization against the provider-authenticated merge ceiling, not write-only provenance. G. The `RunnerRequest` envelope is deleted. The runner and owner ship as one release identity checked by build fingerprint at admission; the messages are private, while the owner-side authority invariants stay public and exact. H. `FactoryTerminal` keeps journal event references, with cross-path, kind, completeness and agreement validation spelled out. I. The factory protocol schemas stay in the factory specification §11.2; the other four documents link rather than duplicate. J/K. Mixed wrapping left alone, new prose unwrapped; the tenth construct is reflected everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
taras#710) Ruling C asked for the comment contracts, the architecture, the WGE evidence and the generic external-effect explanation to agree. The first three landed in 0d08dcc; §10.2 did not. Split the two kinds of reconciled effect where the difference actually lives. An effect that mutates a subject already there — Push, a numbered pull-request update, ready, close, issue close, Project status — reads that subject, and a complete observation is decisive whether or not the effect has attempted anything. An effect that creates a new object the host names has nothing pre-existing to read and no client-supplied idempotency key, so its completion is visible only through a correlation value it wrote itself, and absence means a different thing before and after a mutation has been attempted. Only the second kind carries attempt state, and it narrows the decision in one place: unattempted with nothing found performs once, while attempted with no committed completion and nothing found is permanent ambiguity rather than absence. A provider that cannot write, preserve and completely query such a value refuses the effect from observation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Four narrow corrections from the Architect's verdict on b364b98. 1. The merged-observation wait is not a typed-answer suspension. A machine wait is a second durable wait kind inside the existing lifecycle: it asks nobody anything and ends because a later execution observed a provider again, so it has no response schema, no `xmd workflow answer` route, no form and no bound value, and it publishes no `suspension_request` and consumes no `suspension_answer`. It shares only the atomic boundary — its `machine_wait` event and the `suspended` status commit together, and the acquisition is released after that commit. Its identity is a `waitId`, never a suspension ID; `MergedObservationWait.suspensionId` becomes `waitId`, while `ConflictSuspension.suspensionId` and the typed-answer protocol are untouched. A new `MergedObservationWake` names the same `waitId` and a closed `source`, with `intakeId` required exactly for a provider intake and absent exactly for an operator resume. Waking is permission to look again: the intake retains a bounded notification carrying no answer, verdict, stage, transition or observation result, a later executor consumes it and appends the wake event in one transaction, and a resume without a pending wake or operator authority reports the same wait and settles suspended again. 2. The generic create-effect rule now distinguishes its two safe mechanisms. An effect with a provider-native client key — an Issue upsert, a pull-request upsert — reconciles on that key and carries no attempt state; creating an object is not by itself what makes an effect attempt-stateful. A comment has neither a native key nor a pre-existing subject, which is why it needs the marker and the unattempted/attempted distinction. An effect with neither mechanism refuses before its first mutation, as an intentional constraint. 3. Completed replay may reach and read the run's durable owner — lifecycle storage access, not external-effect replay — while attaching no external-effect provider, performing no effect again and starting no native operation. The "no remote storage session" wording is gone from every document. 4. The authored comment body is preserved byte for byte as the authored portion of the projection, with transport metadata outside it, rather than the provider payload being claimed to equal the authored bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
) The first slice of F1: the seams a remote host needs, and the identity it is addressed by. No Cloudflare code yet — this is the boundary work that has to be true before a second host can exist. `WorkflowExecutionTransitions`, `WorkflowBeginRequest`, `WorkflowExecutionBegun`, `WorkflowForkRequest`, `WorkflowForkSelection` and `WorkflowRunCreation` now export from the package root, which is where they mean what they mean. They were already defined in the provider-neutral lifecycle module but published only through `@executablemd/workflow/deno`, so the shared CLI imported its own return type from an adapter and a second host would have had to load that adapter to name it. The Deno entrypoint keeps its re-exports for source compatibility with a comment saying what belongs behind it — implementations and retained encodings, not the shape of a request — and the CLI and its tests now import from the root. `deriveFactoryRunId()` implements the settled derivation: lowercase unpadded RFC 4648 Base32 over all 32 SHA-256 bytes of `github-issue-v1`, NUL, the canonical GitHub authority, NUL, the exact issue node id. The authority folds case and keeps a non-default port; a scheme, user information, path, query, fragment, whitespace, malformed host or port, and a written-out default port each refuse by name rather than being repaired, because two spellings that both became one authority would be two runs quietly becoming one. The node id is held to non-empty and NUL-free and otherwise passes through byte for byte. Three tests. The identity suite checks fixed vectors computed outside this implementation, the RFC 4648 §10 encoding vectors, that the NUL separators make (authority, node id) unambiguous, and every refusal. A host-neutrality suite walks the shared modules and fails on a host-owned specifier or a runtime detection outside a runtime-named entrypoint, so a Cloudflare import cannot leak into shared code unnoticed. A host-boundary suite pins `WorkflowHost` to exactly four methods and proves it is satisfiable from root-exported types alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…aras#698) `DLC13` in packages/workflow/tests/workspace-effect.test.ts scans `packages/workflow/mod.ts` and `packages/workflow/src/**` (minus the Deno adapter) and refuses a set of names that includes `GitHub`. Its reason is that the shared external-effect boundary exists so any Git host can be adapted to it, and "the first adapter naming itself in a shared contract is how a neutral surface quietly becomes one provider's." 8f27b04 put `deriveFactoryRunId()` under `packages/workflow/src/factory/` and exported it from the package root, which broke that test — correctly. The derivation names GitHub in its scheme tag, its authority rule and its node id, because the software factory is a GitHub product by definition. Move it to `packages/cli/src/factory-run-id.ts`, beside the CLI's existing `github-issues-config.ts` where naming GitHub is already legitimate, and take it back out of `mod.ts`. The module and its tests are unchanged otherwise. The host-neutrality scan added in 8f27b04 no longer exempts `cloudflare.ts` or `src/cloudflare`: neither exists yet, and a scan that exempts a path nothing occupies is a claim about a boundary nobody drew. Where the derivation lives permanently is not settled by this commit. The Durable Object owner needs it too, and F1 cannot place it until DLC13's scanned set is reconciled with a Cloudflare adapter subtree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Applies the Architect's answers to Q1 and Q2 in .vscode/698/698-a-1.md. Q1. `DLC13` now excludes `packages/workflow/src/cloudflare/**` and `packages/workflow/src/software-factory/**`, each with its own reason. The two implementation subtrees are runtime-owned — scanning an adapter for the vocabulary of the runtime it adapts is a category error, and Code Rule 12 puts host behavior behind exactly those names. The software factory is the other kind of exception: not a runtime adapter, still held to the host-import and runtime-detection rules by `host-neutrality.test.ts`, and allowed only the product vocabulary, because §1.1 of the factory specification makes GitHub the subject matter of that contract rather than one provider capturing a neutral boundary. `packages/workflow/mod.ts` and every shared module stay scanned, and no forbidden word gained an exception. Beside the existing Deno assertion, `found` is now checked to contain no `/src/cloudflare/` and no `/src/software-factory/` path, so an exclusion that matched nothing or matched too little cannot pass quietly. `host-neutrality.test.ts` gains `cloudflare.ts` and `src/cloudflare` in `RUNTIME_OWNED` and asserts the Cloudflare subtree is absent from what it scans. `software-factory.ts` is deliberately not runtime-owned there: it uses the cross-runtime Web primitives and is checked like any shared module, which the new assertion that `src/software-factory/run-id.ts` is among the scanned modules now proves. Q2. The derivation moves from `packages/cli/src/factory-run-id.ts` to `packages/workflow/src/software-factory/run-id.ts`, published as `@executablemd/workflow/software-factory` from both manifests. One implementation, reachable by the provider host and by F2's GitHub intake without either carrying a hash that has to agree byte for byte with the other's. The package root does not re-export it — that neutrality is the point — and no forwarding copy is left in the CLI. Its tests move to the workflow package and import the published subpath, so the export map is what they exercise. Q3 needs no change yet; it governs §8 and §9, which are unstarted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Plan §13. `@cloudflare/vitest-plugin@1.1.3` and `vitest@4.1.11` as root development dependencies, which is what brings workerd in — plan §12 requires the real Durable Object namespace, real SQLite storage and real WebSocket admission, and an in-process fake is not evidence for any of them. Both lock layouts are updated in the documented order: the pnpm add, then `deno install --frozen=false`, then `deno task setup`. `pnpm-lock.yaml` gains 1231 lines and deletes none; `deno.lock` gains the npm graph for the same packages. Two things worth knowing for anyone reproducing this. The lockfile is `lockfileVersion: '9.0'` and `package.json` declares `pnpm@9.15.0`, but the pnpm on PATH here is 7.5.0, which cannot read a v9 lockfile — it warns "Ignoring broken lockfile" and rewrites it as `lockfileVersion: 5.4`. That downgrade happened once and was reverted; this commit was produced with the declared version through `corepack pnpm`. Anything that shells out to a bare `pnpm`, `deno task setup` included, needs 9.15.0 ahead of 7.5.0 on PATH or it will silently downgrade the lockfile again. pnpm 9 also re-sorts `package.json` dependency keys, which is why `effection`, `mdast-util-to-string` and `zod` move. That is the package manager's own canonical ordering; hand-restoring it would only be undone by the next install. `deno task setup` still exits 1 at its last step, `build:web`, with `Module not found "file:///…/bundle"` from `deno bundle --packages=bundle`. That is not this change: the identical argv succeeds when run directly and under a preflight-shaped parent, it fails only through `@effectionx/process`'s `exec()`, `packages/web/generated/` has never existed in this checkout, and the browser bundle's module graph does not reach vitest or workerd. `deno task check`, `deno task lint` and the focused suites all pass on the new dependency state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
`@executablemd/workflow/software-factory` now publishes only `deriveFactoryRunId`, `admitFactoryRunSubject`, `FactoryRunSubject`, `FactoryRunSubjectFailure` and `FactoryRunSubjectError`. The scheme tag, the Base32 alphabet, the authority rule, the preimage layout, the encoder and the length constant are implementation: a caller that could reach them could also reimplement the hash, and two implementations of an identity that must agree byte for byte is the failure §1.1 exists to prevent. `factoryRunIdPreimage()` and `base32Unpadded()` stay visible to their own module so the encoding tests can prove an internal algorithm, and are absent from the entrypoint. Everything the tests assert about public behavior — the fixed vectors, the 52-character shape, case folding, the non-default port, and every authority and node-id refusal — now goes through `admitFactoryRunSubject()` and `deriveFactoryRunId()`. The `as BufferSource` assertion is gone. `factoryRunIdPreimage()` returns an `ArrayBuffer` rather than a view, which is what `crypto.subtle.digest()` accepts with no assertion at the call site, so Code Rule 6 holds without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…aras#698) Plan §12's harness, and the first thing it was built to answer. `vitest.config.ts` at the repository root runs `*.vitest.ts` under `@cloudflare/vitest-plugin` against real workerd, with a wrangler config declaring `StorageProbeObject` as a `new_sqlite_classes` Durable Object. The suffix keeps these files out of `deno task test`, `pnpm test:node` and `bun run test:bun`: discovery matches `*.test.ts`, and `scripts/tests/test-file-discovery.test.ts` still agrees with it, so nothing is stranded and no runtime exclusion was needed. `pnpm test:cloudflare` and `deno task test:cloudflare` run it. Two things had to be settled to get it running. The plugin binds to `vitest` through peer dependencies, and pnpm was resolving a second `vitest@4.1.11` copy with a different peer set, so the pool was configured but never took over and every test reported "failed to find the current suite" — adding the plugin's declared `@vitest/runner` and `@vitest/snapshot` peers at the root collapses that. The config lives at the repository root rather than in the package, because a package-level config resolves `vitest` from the package's own isolated `node_modules` and multiplies the copies again. `StorageProbeObject` asks the runtime what it accepts rather than assuming. Recorded on workerd 1.20260831.1: - `PRAGMA application_id` and `PRAGMA user_version` — refused, read and write, with `not authorized: SQLITE_AUTH`. - `sqlite_schema` introspection, ordinary DDL, and a plain metadata table — all fine. - One outer `ctx.storage.transactionSync()` — fine. - A reentrant `transactionSync`, and a direct `SAVEPOINT` — refused: the runtime requires its own transaction API instead of SQL transaction statements. - DOFS schema initialization and DOFS filesystem writes outside a transaction — fine. - A DOFS filesystem write *inside* one `transactionSync` — refused, because `writeFileSync` opens a `transactionSync` of its own and that nests. The last two contradict plan §4, which has the owner reuse the pragma-based recognition the Deno host uses and commit Workspace mutations inside one `ctx.storage.transactionSync()`. Neither is possible as written. The probe is committed so the finding is reproducible rather than reported. An earlier version of the probe called the asynchronous `WorkspaceFilesystem` wrapper and never awaited it, which reported success for work that had failed. It now uses the same synchronous primitives the Deno provider imports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
`@cloudflare/vitest-plugin` binds to `vitest` through peer dependencies, and the pool only takes over when the plugin and the CLI hold the same instance. pnpm was producing two: `acpx` depends on `tsx@4.23.1` exactly, the root range `^4.19.0` resolved to `4.21.0`, and `vite` peers on `tsx`, so `vitest@4.1.11` existed twice under different peer contexts. The plugin bound to one and the root link to the other, which is why the pool reported itself configured while every test failed with "Vitest failed to find the current suite". One `pnpm.overrides` entry pinning `tsx` collapses that. Both the root link and the plugin now resolve the same `vitest`, and `pnpm test:cloudflare` passes. Ordering note for anyone reproducing this: `deno install` prunes the links pnpm placed — `scripts/deps.ts` says so in its own header — so `deno task setup` leaves the pnpm-owned test tooling unlinked and `pnpm install` afterwards restores it. Setup itself completes: exit 0, "ready", browser bundle generated, lockfile still v9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
taras#698) The storage correction from `.vscode/698/698-c.md`. `packages/workflow/src/sqlite/workflow-schema.ts` now holds version 1 once: the application identity, the logical version, every declared table and index, the DDL, the expected inventory, the pre-release shapes, and `declaredStructureFailure()`, which compares what a database declares against what this build writes and answers with the disagreement instead of raising it. Two adapters keeping their own copy of that DDL would be two schemas that happen to look alike, and the first amendment either missed would be a run neither could recognize. The module names no runtime, owns no connection, path, transaction or lifecycle authority, and is published from no entrypoint. `src/deno/schema.ts` loses 505 lines and keeps what is genuinely its own: `DatabaseSync`, filesystem paths, SQLite error translation, and its existing `PRAGMA application_id`/`PRAGMA user_version` carrier. It reports the shared finding as its own failures, so the Deno host's behavior and its released recognition are unchanged. `src/cloudflare/marker.ts` is the other carrier. A Durable Object's SQLite refuses both pragmas — `not authorized: SQLITE_AUTH`, on read as well as write — so that adapter records the same two values in `_xmd_workflow_schema`, a singleton row fixed at `id = 1` by a CHECK and a primary key so a second identity row cannot exist. Same logical version, different physical carrier; adapter-private recognition metadata, not a WorkflowRun record, journal value, exported field or second schema. `src/cloudflare/storage.ts` reconciles the runtime's concrete `SqlStorageValue` rows with the vendor's structural row type in one place, and deliberately does not forward `transactionSync`. `DLC13` excludes `src/sqlite/**` with its own recorded reason and asserts the subtree is absent from what it scanned; `host-neutrality.test.ts` keeps scanning it and now proves it is among the modules checked. The exploratory probe moves out of production source into `tests/cloudflare/support/`, and its console dump becomes three asserted tests matching stable categories rather than platform wording: the pragmas are refused as unauthorized; direct savepoints, reentrant transactions and a DOFS filesystem write inside an owner transaction are all refused toward the storage transaction API; and ordinary DDL, `sqlite_schema`, an outer transaction, DOFS schema initialization and a strict metadata table are accepted. `pnpm check:cloudflare` type-checks the Cloudflare production source and its tests against `@cloudflare/workers-types`, which the root Deno check cannot do because `cloudflare:` modules do not resolve there. Two `pnpm.overrides` pin `tsx` and `@cloudflare/workers-types`: both are peers that were splintering `vitest` and the plugin into several instances, and a single copy of each is what makes the pool engage. `@cloudflare/workers-types` is pinned to `5.20260831.1` rather than the newest, which Deno's minimum-dependency-age policy blocks. Ordering, unchanged from `670ad0c` and now deterministic in both directions: `deno task setup` completes and leaves the workerd suite unable to run, and a `pnpm install` afterwards restores it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The Cloudflare owner's storage paths, and the evidence that they hold. `src/cloudflare/owner-transaction.ts` is the transaction every authoritative commit runs inside. It enters `ctx.storage.transactionSync()` once and hands DOFS a wrapper whose `transactionSync` runs its callback directly — inside the real callback that is not a weaker promise, because the outer transaction is already open, and it is the only way DOFS can participate at all: asked to transact while it believes one is open, the vendor falls back to `SAVEPOINT`, which the runtime refuses, and every DOFS filesystem primitive opens a transaction on the way in. The enlistment is created for one callback, refuses use outside it, and clears the DOFS resolve and blob caches on both sides so nothing populated from rows a rollback discarded is read later. The vendored snapshot is untouched and `deno task vendor:verify` still passes. `src/cloudflare/recognition.ts` initializes pristine storage in one transaction — schema, DOFS schema, run row, then the marker last, so the code says what the marker means even though atomicity hides the ordering — and recognizes it again through the same four conditions the Deno host distinguishes: foreign, unsupported version, corrupt, or a version-1 run. Fourteen assertions on real workerd, in two suites. Initialization writes the marker and is recognized again; storage that already holds an object is refused rather than written into; nothing at all, objects without a marker, another application's identity, version 2, version 0, and a dropped table are each refused as their own condition. A mixed commit publishes a DOFS filesystem write and a WorkflowRun row together. A body that throws after changing both leaves neither — the run row is unchanged, the file is absent, the object still recognizes, and a later commit succeeds from the frontier the failure left. That last test is the one the whole design turns on. `pnpm check:cloudflare` type-checks all of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
`test-cloudflare` runs `pnpm check:cloudflare` and `pnpm test:cloudflare`, and is in `green.needs`. It runs on every event and carries no condition, so `green` requires success from it unconditionally. The job owns evidence no other job can produce. A Durable Object's acquisition lifetime, its eviction and its transaction atomicity are properties of that runtime, and the `.vitest.ts` files proving them are invisible to the Deno, Node and Bun corpora by design — so without this job the evidence would simply stop running while everything else stayed green. `ci-workflow.test.ts` already fails when a job is missing from the aggregate; verified by removing the entry and watching "requires every other job and no future job can be omitted" fail. Added a test naming this job's two commands as well, so what it is *for* is legible rather than only that it is depended on. `pnpm install` is the job's last preparation step: `deno install` prunes the links pnpm placed, and the plugin only takes over the pool when it and the CLI hold the same vitest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The executor plane: who may advance a run, and what they may say. `release.ts` compares an exact build fingerprint. `admission.ts` holds verified OIDC claims to a configured policy — issuer, audience, repository ID, owner ID, event name, workflow ref, workflow SHA and the immutable workflow identity — checking IDs rather than names, because a repository can be renamed and a check on the name would admit whoever holds it today. It reads a closed claim set, so a claim this contract does not name cannot be depended on, and it takes claims a verifier already authenticated rather than a token: how a signature is checked is not this module's business. `acquisition.ts` makes the connection the acquisition. Hibernation is why it cannot be a field — an evicted object has no memory of what it admitted — so authority is the pair the runtime hands back: `getWebSockets()` says which sockets are real and a bounded attachment says what one was admitted as. Copied attachment bytes prove nothing, because the question is not whether a value looks right but whether this socket is the one live socket holding an acquisition. There is no lease, expiry, renewal, heartbeat, alarm or poll. `commands.ts` reads the private transport strictly: unknown command, unknown member, wrong kind, oversized message, too many chunks — each refuses whole, nothing is partially adopted. The answer is a serialized record keyed on `outcome` rather than an Effection `Result`, because an `Error` does not cross a connection; Code Rule 13 governs in-process results and this is not one. `owner.ts` is the Durable Object. Its admission order is the contract: build before token, token before run, acquisition last, so a refusal at any step leaves no acquisition and no state. A message proves its acquisition before it is parsed. A closed connection releases ownership, rolls nothing back and settles nothing — an executor that disappeared decided nothing. `routing.ts` selects the owner arithmetically and admits the run ID first, because `idFromName` answers for any string and a mistyped id would silently address a fresh, empty owner. Twenty-nine assertions on real workerd across three suites. A mismatched build is refused while its claims are deliberately unusable, proving the order. Every policy claim is refused one at a time. A second healthy executor is refused rather than followed. A closed connection owns nothing and the next executor may take it with no lease having expired. A stranger's socket is refused before its command is read. `./cloudflare` is an npm export only: JSR cannot typecheck a `cloudflare:` entrypoint, and `deno task check:jsr` stays green because the Deno host is what JSR serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
`transact()` for a run whose storage is somewhere else, as `698-a-1.md` Q3 settles it. A Durable Object commits synchronously and cannot hold a transaction open across a network wait, so the obvious reading is unavailable. What makes it tractable is that the body does not need the transaction open while it runs: it needs the starting history, somewhere for its writes to go, and all of them landing together. So the callback runs in a runner-owned scope against a collector. The starting frontier is one bounded read that opens and closes its own owner-side read. Appends go into a local buffer that `readAll()` reads back after the starting prefix, so a body sees its own writes. Nothing is sent while the body runs; when the body and everything it started have torn down, one closed intent goes to the owner. The callback is never serialized, interpreted or executed on the owner. That is what makes arbitrary control flow safe — nothing tries to infer what the body did, and only what it *enlisted* travels. `src/remote/collector.ts` rather than `src/cloudflare/**`: this is the client half and it runs on the runner, not in the Worker. It names no host, so the ordinary Deno check and both boundary scans cover it. Nine assertions with a deterministic fake link, because Cloudflare mechanics are not the subject — what the owner does with an intent is proven on workerd, what the client sends is proven here. One intent carries what was enlisted and the frontier it was proposed against. `readAll()` gives read-your-writes. A body may cross a suspension point with no owner transaction open. A body that throws sends nothing and leaves the gate closed. An owner refusal is returned instead of the body's value, which never crossed the connection. A nested transaction and an ordinary same-handle operation inside a body each refuse. A handle used after its body closed refuses. And an event mutated after it was appended commits as it was handed over, because the collector cloned it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The runner half of the connection. `src/remote/client.ts` holds one connection open for the calling scope and correlates answers by the id they name rather than by arrival order — a socket delivers what the owner sent whenever it sent it, and a client that assumed order would attribute one command's refusal to another. Teardown fails every request still waiting, because a caller blocked on an answer that can never arrive would outlive the connection it asked through. It decides nothing about the run. A refusal comes back as an answer rather than a transport failure: what a command means is the owner's, and a client that interpreted a refusal would be a second place deciding what a run may do. Seven assertions against a socket a test drives by hand. The command reaches the wire carrying its id; two answers returned in the opposite order to the asking still reach the right callers; a refusal is handed back as an answer; an answer that is not JSON and an answer naming nobody are both dropped without disturbing the caller that was waiting; a connection that ends fails the request in flight and refuses the next one; and a second request under an id already in flight is refused rather than silently replacing it. This is client code and it names no host, so it lives beside the collector in `src/remote/` where the ordinary Deno check and both boundary scans cover it. Not included, deliberately: `packages/cli/src/remote-workflow.ts`. The four- method assembly composes client operations — a remote `WorkflowRunDatabase`, lifecycle, delivery and inspection over this connection — and those do not exist yet. A host whose four methods all raise would be the placeholder the plan says not to add, so the assembly waits until there is something real to assemble. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Corrections 1 and 2 from `.vscode/698/698-c-2.md`. The first is a real hole I opened, and the review is right about it: `admit()` took an `AdmissionRequest.claims` and compared its values to the policy, so a caller could assert every configured identity and obtain the acquisition without ever holding a signed token. A comment saying a verifier had authenticated them was not an authority boundary — `OwnerConfiguration` held no verifier, and the claims arrived through the same request surface the owner distrusts. `src/cloudflare/token.ts` verifies a compact JWS: one algorithm family by allowlist rather than by reading `alg` and believing it, keys the deployment configured, signature, then temporal validity — and only then is a payload member read as a claim. The raw token, the key material, the header and the claims the policy does not name stop there; none is retained, attached, journaled or returned. `admitClaims()` and `parseClaims()` are no longer exported. `admitToken()` is the only way into that module, because an exported "check these claims" is precisely the surface that made this forgeable. `AdmissionRequest` now carries the raw token and has no member for a verified result, a claim set, an acquisition identity or verification material — a request that could name any of those would be a request choosing what it is allowed to be. Verification material is closure state on the owner. `admit()` is an Effection operation, since verification suspends; the test owner drives it through one scope at the runtime callback boundary. Correction 2: the acquisition correlation is minted on the owner after both checks pass, from `crypto.getRandomValues`, and is no longer a caller argument. It partitions acquisition-private staging and duplicate handling and is not a bearer credential — the exact live socket is still what proves a message may act. Thirty-five assertions. Tokens are signed with an RSA key pair generated in the test process, so these are real signatures rather than a stub that agreed. A correctly signed token admits; an edited payload, a wrong key under a configured key id, an unknown key id, `alg: "none"`, an absent token, a non-JWS, an expired one and a not-yet-valid one each refuse before acquisition and before state. The wrong-release test now presents an unusable token, so it still proves the build is compared first. Two sequential acquisitions receive different correlations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…ed (taras#698) Corrections 3, 4 and 5 from `.vscode/698/698-c-2.md`. All three were real. Correction 3. `transactRemotely()` checked `gate.open`, then suspended in `frontier()`, and only took the gate afterwards — so two calls on one handle could both pass the check and act from the same starting frontier. It also released the gate when the body ended, before `commit()` returned, so later work could run while which state won was still undecided. The gate is now taken synchronously before the first suspension and released in one finalizer after the commit answer, on success, refusal, body failure, transport failure and cancellation alike. The transaction object still closes when the body does, so a retained handle refuses while the handle-level gate is held. Correction 4. `append()` cloned, but `readAll()` handed back references into the collector's own arrays: a body could read an event and mutate what it received, and the committed intent would differ from what `append()` admitted. Every crossing is now a fresh copy — in, out, and into the intent — and the collector's array never leaves. Events are admitted rather than assumed, with a count bound and an aggregate serialized-byte bound; an invalid or oversized one fails locally and sends nothing. Correction 5. The connection dropped malformed answers and answers for unknown ids, which meant a malformed reply to an in-flight commit left the caller waiting forever while the owner may already have committed. All three of those are evidence that the two sides disagree about which command completed, so the channel now fails closed: an unreadable answer, an answer naming a request nobody made, and a second answer to a request already settled each stop the channel, close the socket and reject every waiter. Owner refusals remain typed answers, and answers are bounded. Nine assertions on the channel and fifteen on the transaction. Two of the transaction ones are the concurrency cases the review asked for: a second transaction refuses while the first is suspended in `frontier()`, and the handle stays owned while a blocked `commit()` is undecided. Two more prove detachment from both directions — a reader mutating what `readAll()` returned, and a caller mutating what it appended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
D1 item 1, and Ruling 1 from `.vscode/698/698-c-4.md`. The owner has to parse a settle request with the shared `parseDocumentExecutionCompletion()`, and could not: `storage/record.ts` imports `canonicalize` from the `@executablemd/core` root, whose barrel reaches `node:crypto`, `node:process` and `node:readline/promises`. None resolves in a Workers typecheck, so a pure function was unreachable because of where it sat. `canonicalize()` moves to `packages/core/src/canonicalize.ts`, importing only a type. `canonicalFingerprint()` stays in the Node-capable module, importing the pure function and `node:crypto`. Both keep their package-root exports and their behavior, and `@executablemd/core/canonicalize` publishes the pure half. Two more predicates blocked the same graph for the same reason, which the ruling anticipates and authorizes handling the same way. `isComponentName` was co-located with the registration machinery and `isCanonicalTarget` with the document-target catalog and its Markdown parser; both are string arithmetic. They move to `src/component-name.ts` and `src/document-target-spelling.ts`, with `@executablemd/core/component-name` and `@executablemd/core/document-target` selecting them. `storage/definition.ts` imports through those. No copy of any of them exists — each original module re-exports the leaf, so there is one implementation and the root surface is unchanged. This is not a `portable` barrel: three narrowly named leaves, each holding what it is named after. Two adjustments fell out. `tsconfig.cloudflare.json` no longer sets `exactOptionalPropertyTypes` or `noUncheckedIndexedAccess`: it was stricter than the repository holds itself to, so it failed pre-existing shared code the Deno check accepts, and a check that invents rules proves the wrong thing. And the percent-decoder now states `ignoreBOM: false` — already its behavior everywhere, and Cloudflare's own type declares both `TextDecoder` options required. Evidence. `packages/core/tests/canonicalize.test.ts` proves root and subpath answer identically, including for `__proto__`, and that the fingerprint still composes over the same ordering. `settle-parser.vitest.ts` runs on real workerd, which is the assertion that matters: it loads the shared parser where a Node builtin is genuinely absent, so a test-only import could not have passed for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
D1 items 2 through 6 from `.vscode/698/698-c-4.md`.
**Token.** `verifyToken()` accepted a token with no `exp`, and a non-numeric
`exp` or `nbf`, because it checked those claims only when they happened to be
numbers — so omitting one was treated as satisfying it. All three of `exp`,
`iat` and `nbf` are now required finite integer NumericDates. The expiration
boundary is expired, per RFC 7519 §4.1.4's "before". An `iat` in the future
beyond tolerance is not-yet-valid rather than accepted. The header must say
`typ` is a JWT, and `kid` must name exactly one configured key: the old filter
fell back to an unkeyed candidate, so an unrecognized key id still got a
signature check against whatever else was configured. The token and its segments
are bounded before anything is decoded, and skew is bounded at both ends —
negative would reject tokens for being on time, and unbounded is
indistinguishable from not checking.
**Transaction ownership.** The module-global `let open` is gone. It was shared
by every Durable Object in an isolate, so one object's transaction would refuse
another's. The ruling suggested a `WeakSet` keyed by storage; this repository's
`local/no-module-scoped-registry` forbids that too, and for a related reason. So
the claim belongs to the object: `OwnerTransactions` is created and held by the
owner, its lifetime is the object's, and no other object can see it. Same
guarantee, no process-lifetime table.
**Settle.** `{ status: string }` becomes a closed request carrying
`completion: DocumentExecutionCompletion` and `expectedWorkspaceRootId`, with
the completion read by the shared `parseDocumentExecutionCompletion()` — a
failed parse becomes this transport's own `malformed-member` without carrying
the parser's message, which names members a request supplied.
**Answers.** `ask()` now takes the parser for its own success value and returns
`OwnerAnswer<T>`. `unknown` exists only at the JSON boundary; a value the
command's parser cannot read fails the channel closed like any other
disagreement about what completed, and a refusal is delivered without consulting
the parser at all. The generic stays inside the closure the request built, so
the reader settles an answer without asserting what it is.
**Removed claims.** `duplicate-conflict` is gone until the mechanism that
produces it exists. `perform()` stays abstract and the echo stays test-only.
Every checked-then-asserted `Record<string, unknown>` became `Object.entries`
narrowing, and the one double assertion in the tests became a helper that passes
`unknown` through. One documented assertion remains, in `storage.ts`, bridging
two type declarations of the same runtime rows.
Forty-eight workerd assertions and thirty portable ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Closing two gaps in D1 item 5 that I found auditing my own work against the list, rather than leaving them for review. The refusal was checked to be a string and nothing more, so an arbitrary remote sentence became this side's public failure identity — and a refusal is something a caller branches on. It is now held to the category shape the owner actually produces, and the correlation id is bounded. Anything else is an answer this build cannot read, which fails the channel closed like every other disagreement about what completed. Item 5 also asked for cancellation cleanup evidence and there was none. The test asserts what is observable rather than what I first assumed: Effection halts a cancelled task instead of raising into it, so the proof is that the scope completes at all with a request in flight — a waiter nothing settled would hang teardown — and that a late answer afterwards reaches nothing and raises nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The runner's socket is the executor acquisition, and D1 left it behind. The resource installed listeners and never removed them, never closed the socket, and never observed `error`, so a scope that ended — normally, by failure, or by cancellation — left the owner looking at a healthy socket that no longer had anybody on the other end. With no lease and no heartbeat by design, that made the run unadvanceable by anybody, forever. Teardown is now one idempotent operation with one owner. Scope exit, cancellation, a remote close, a socket error, a protocol failure, a command-specific parse failure and a failed send all reach it; it runs once, removes the exact callbacks it installed, and closes the socket once. The finalizer is registered before anything can suspend. Answers are now read where they arrive rather than through a signal the reader drained later. That ordering is the point: a close arriving in the same turn as an unreadable answer used to reach teardown first and tell the caller `closed` for something that was actually `malformed-answer`. What went wrong is decided where it is observed. The envelope is closed for real: each outcome declares its whole key set, so a performed answer carrying a `refusal`, a refused one carrying a `value`, a missing member and an unknown member are all refused. An outgoing correlation id is held to the contract an incoming one is held to, and refusal text gets its own small bound. The regular expression proves spelling, and the comment now says so — narrowing to the declared union stays the adapter's job. A retained Workspace root is a content identity, so every command that names one parses it as 64 lowercase hexadecimal characters rather than as any non-empty text. The transaction machinery is no longer exported from the Cloudflare subpath. It stays private to `src/cloudflare/**`, owned by the object. The old teardown test asserted nothing: its fake retained the message callback, so the connection could leak both listeners and the socket and still pass. The fake now counts closes and live listeners, and the evidence is what those counters say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
An admitted connection could prove who it was and ask nothing. This gives it the reads a runner actually needs — the committed frontier, one retained Workspace root, and the content that root names — and the private mechanics those reads depend on. Reads are bounded and coherent. One frontier request returns the parsed run record, the current canonical root and the last journal event that existed at that moment; the journal itself comes back in anchored pages, so a journal larger than one message is still one snapshot and later appends cannot enter an earlier one. Each page carries its own predecessor, and the runner reassembles them by checking rather than trusting: a page that skips, repeats, reorders or ends in the wrong place closes the connection before one event reaches a caller, because half a journal that looks whole is worse than none. A root comes back as its canonical manifest, then one referenced piece at a time. The owner proves the manifest is canonically encoded, that its identity is the digest of its own bytes, that the retained references are exactly the ones the entries name, and that every piece it sends is referenced by that root and hashes to the digest it is asked for. The runner proves it all again on arrival. The owner being honest is not evidence about the wire, and content that is not what it is named must never become a materialization. A runner that hears no answer cannot tell a lost question from a lost answer, so it asks again. That is only safe if asking twice is asking once. Every command is now decided once per acquisition and its decision retained beside it. Two requests are the same request when their parsed commands are equal, so member order does not make a retry into a new command and a changed value does: reusing an id for something else is refused as a conflict rather than answered. Reads whose answers are fixed by immutable state and an anchor the request already carries are remembered as a decision to read again; the frontier is kept whole, because it is the one read whose answer would otherwise move, and a retry that returned a later frontier would hand back a snapshot nobody asked for. The ledger never evicts while the acquisition lives — dropping an id would make a retry look new, which for a mutation is the difference between doing something once and twice — so a full ledger refuses and fails closed. Content a runner offers is staged, and staging is not publication. It is digest-checked, bounded per piece and in aggregate, stored detached, keyed by the acquisition that offered it, and visible to no retained read. Adopting it is a later checkpoint's transaction. Both private tables live in SQLite because an evicted object remembers nothing and the attachment is 16 KiB of identity, not somewhere to grow a ledger; a replacement acquisition discards its predecessor's scratch before it accepts, and touches no retained history doing it. The rules a root is held to now live in one place instead of two. Both hosts retain the same roots and must name them identically, so the manifest format, its canonical encoding and the stored-row parsers moved beside the schema they belong to, and the Deno host reads through the same implementation it always described. The digest is arithmetic in the language: a shared module cannot import a host's crypto, and the one place this is needed most is inside a synchronous transaction, where there is nothing to await into. `commit` and `settle` parse strictly and refuse. Applying them is D3 and D4, and a placeholder that reported success would be the one answer a runner cannot recover from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
A root the owner returns is where the run stands. The runner materializes it, works inside it, and proposes against it. So a root whose content cannot all be found is not a frontier that happens to be incomplete — it is not a frontier, and saying otherwise is the one answer that cannot be taken back. The reads checked the root manifest and the manifests its entries named, and stopped. Everything past that — whether those manifests were still there, what their bytes were, whether the blobs they named existed, whether the root retained references to exactly those blobs — was checked only when a runner later asked for that particular piece. A root missing half its content therefore answered `frontier` and `root` performed, and failed afterwards, one piece at a time, once the run had already been told where it stood. Now the graph is walked before either answers. The manifests the entries name must be exactly the manifests the root retains; each must exist, be bounded, decode canonically, hash to its identity, and agree with its recorded size and with every file naming it. The blobs those manifests name must be exactly the blobs the root retains; each must exist, be bounded, hash to its identity, and agree with its recorded size and with every chunk naming it. Both directions of each reference set are checked: a missing row is content nothing is keeping alive, an extra row is content no manifest accounts for, and neither describes a root anybody should start from. The bytes are read and dropped. What survives the walk is the proof, and a content request still re-reads the single piece it sends — a validated root is not permission to answer with all of it at once. The schema already refuses to let content vanish from under a root that references it, so the states worth reproducing are the ones that restriction cannot prevent: a reference collected together with its content, bytes that no longer hash to the identity they are stored under, a recorded size that disagrees with what it describes, and a reference to content no manifest names. Each of those now refuses before a root is returned, and says only that storage is damaged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The owner holds the run and can run nothing. Git, an Agent and an evidence command need real files, so a root has to become a directory somewhere the runner owns, and whatever happens there has to come back as a root again. The equality that matters is that those two operations compose to nothing: a materialization nobody touched must capture to the exact root it came from. If it did not, every Workspace operation that changed nothing would still propose a new root, and the owner could not tell a real change from an artefact of how the runner unpacked the tree. Directories, an empty file, a symbolic link, a hardlink group, modes and modification times all have to survive for that to be true, which is why the evidence is a real temporary filesystem rather than a map that would only prove it kept what it was given. Ordering, hardlink numbering, manifest encoding and chunk size now live beside the format instead of inside whoever happened to walk the tree. The local host walks SQLite rows and the runner walks a directory; those walks cannot be shared and their meaning must not diverge, because a root identity is a digest of the encoding and two encodings would be two names for one Workspace. The materialization knows no runtime and no path. Native operations arrive injected, adapted from the runtime's asynchronous primitives with `until` where `@effectionx/fs` has no equivalent; the logical root stays `/`, and the temporary directory the invocation happens to use reaches no manifest, event, proposal or error. A run that recorded where it was unpacked could not be resumed anywhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Materialization indexed a hardlink group's first path by the content digest of its bytes. Two groups holding identical bytes legally share one DOFS manifest and are still two inodes, so the second group linked to the first and recapture saw one group of four names where the root said two of two. The Workspace that came back was a different Workspace, arriving under the identity of the one that was asked for. Group membership comes from the root's own `hardlink` value now, which is the only thing that ever said what a group was. Modes and times were left to whatever creation happened to produce. A creation mode is narrowed by the process umask, so a root retaining a group-writable file materialized without that bit; a symbolic link's own mode and time were never restored at all, and `utimes` could not have done it without following the link — which may point outside the tree deliberately. Permissions are now set explicitly after creation, deepest-first so a mode that forbids writing is not applied while children are still arriving, and a link's own metadata is set through the operations that do not follow it, where the platform has them. Where a platform has none, materialization refuses. Every entry is read back and compared with what the root declared before anything executes against the tree: a host that cannot represent a legal retained mode or time says so, once, before native work begins. Quietly normalizing it would hand the run a Workspace whose durable identity differs from the history it accepted, and the run would have no way to notice. The round trip now carries two hardlink groups with identical bytes, two independent files with identical bytes that must stay independent, modes the test's own umask would narrow, and a symbolic link with an old time of its own. Reverting the grouping key alone fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The shared coordination boundary is scanned for host vocabulary, and the root manifest module had been failing that scan since D2: its refusals named DOFS in string literals, which the scan reads as code because they are. D2's evidence list did not include the scan, so nothing said so until this checkpoint's wider verification ran it. The names were a symptom of the wrong seam. A Workspace root names a file's content by one identity and says nothing about how those bytes are kept; how they are kept is a separate format, and it was sitting inside the module that describes roots. It now has its own, which decides only whether a sequence of bytes is a canonically encoded manifest and produces the bytes one ought to be. Its refusals describe the format rather than the store implementing it. That is not a rename to satisfy a scan: every host keeps content this way, the vendored layer is one implementation, and a neutral module naming that implementation would be the Workspace surface learning where it happened to be stored. No message this reworded is asserted anywhere, so nothing observable moved. The obvious home — beside the schema that declares the content tables — is closed. A neutral module importing from there names the storage engine in its own import specifier, which is the same crossing by a different route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
`commit` carried a proposed root identity and nothing that could justify it. An identity with no manifest and no content closure is a name, and an owner adopting one would be taking the runner's word for what a root contains. The command now carries the whole thing — what the runner started from, what it proposes, the canonical manifest that identity is the digest of, the exact content that manifest closes over, the retained mappings the same operation produced, and the filtered events to append — and the owner recomputes all of it before anything is written. The frontier is re-read inside the transaction rather than before it, and compared with what the runner said it started from, root and terminal event both, `null` included exactly. A frontier read outside the transaction is a frontier that can move before the write. The inventory has to be exactly the closure of the proposed manifest: every manifest its file entries name, every blob those manifests name, once each, and nothing else. A missing piece is a root that cannot be materialized; an extra one is content the root does not account for. Each piece resolves from content already authoritative or from bytes this exact acquisition staged — staging supplies bytes and grants nothing, so another acquisition's scratch is unreachable and a digest already retained under different bytes is a disagreement rather than an overwrite. Everything lands together or not at all: content, the immutable root and its exact references, the mappings, the current pointer moved by compare-and-set from the expected root, the journal rows, and the retry decision. Journal rows name the root this commit selected — the proposed one when there is a publication, the unchanged one when there is not — which is what makes history readable against the Workspace it happened in. A journal-only transaction and an empty one are both ordinary, and neither invents a Workspace change to look uniform. Retained mappings go through the parsers the local host holds its own rows to, and creation identity is immutable: a second proposal naming the same Repository must describe the same Repository. An Agent-session mapping carries the canonical assertion and the derived key and nothing of the conversation; the owner never contacts a provider. The evidence runs on real workerd because none of it is provable otherwise. A forced failure after every category has been written rolls all of them back, the retry decision with them, leaving the id free. A lost response retried across eviction publishes once. A moved root, a moved anchor, an identity that is not its manifest's digest, an inventory missing or inventing a piece, unstaged content, a mapping that would rewrite an established identity, and a foreign socket each change nothing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Continuing a fork asked the destination to take a request's word for what fork it was. The command named no source and no checkpoint, and the lineage row was only checked for existing — so a request naming somewhere else entirely could take up this destination as though it were the fork it asked for. It now names the source and the checkpoint it claims, and the destination compares them with what it retains. The two head records are held to their exact associations rather than to membership. Proving that a head names some root this store happens to hold proves nothing: every retained root satisfies it. So the roots those two rows were committed against are retained with the lineage when the fork is made, and a head reassociated to another perfectly valid root is refused as the different fork it is. A decision that granted an execution is only re-observable once that execution is this acquisition's. A continuation records which execution it began — and records nothing when it began nothing, because a conflict and a refusal are answers too — so a replacement acquisition either adopts the exact open execution atomically or is told the run has moved past it. What it can no longer be handed is a database paired with an execution it may not settle. An exact command that was resent is now heard out. Every outcome is classified before anything else is sent: a second silence keeps the claim and sends nothing further, a refusal or conflict ends the question, and one closed answer — the destination holds nothing and was offered nothing — is the only one that sends the same logical fork back to its source to stage again. A lost cancellation is kept the same way, so the cancellation that committed is the one the next attempt hears about. A call that is interrupted lets go on purpose. Every transition registers how it ends before it can be interrupted, and where it was interrupted decides what that means: nothing sent, so the guard lifts and the acquisition is free; a command sent and unanswered, so the acquisition is retired instead — because the one thing that must not follow an unknown decision is a fresh mutation racing it.
A fork asks its destination several things under one call: whether it already holds this fork, an offer for every member of the snapshot, and the commit. All of them went out under the call's single identity, and an owner keys a retained decision by the identity it was asked under. So the commit met the continuation's own fingerprint and was refused as a repeat of something it is not, and a fork that had staged everything could never be committed at all. Each question now carries its own name, derived from the call and from which question it is — a part's section and position included — so two of them never collide, and a retry still spells every one of them exactly the way the first attempt did. The staged parts stop being minted fresh per attempt for the same reason: a part the destination already holds has to be recognizable as that part rather than arriving as a new one. An acquisition retired while an outcome is unknown now ends its connection. Retiring the lock and leaving the socket open left the owner holding a live executor nobody was allowed to use: this runner would send nothing more on it, and no one else could take the run while it was held, so the run was unreachable by anybody until the enclosing scope happened to end. The connection is the acquisition, so it goes when the acquisition does — through the same teardown that scope exit reaches, once, with whoever was still waiting told the connection closed.
…its question A destination that answers `needs-transfer` has answered. It says the run is not there and this connection offered it nothing, and it says so under the exact name it was asked by. The fork then staged the whole snapshot again and sent its final command under that same answered name, which the client refuses before it reaches an owner, because a settled correlation may not carry a second question. On the one recovery path this answer exists for, no fork could commit at all. So what follows a `needs-transfer` is a second transfer, and it carries an identity of its own: a new command name for the commit, new names for every offered part, and a new execution to begin, because the answered decision began nothing to inherit. An invocation now says which of the two phases it is in, so a retry resumes the phase it is in rather than reaching back for a name that has been answered — and it no longer asks whether the destination already holds this fork, having just been told it holds nothing. A cancelled call keeps the question it was asking. A transition registers what to retain before each command goes out and clears it when the answer comes back, so an interruption with an owner decision outstanding retains that exact identity — and the exact bytes, when a fork's final command is what was outstanding — before it retires the acquisition and closes the connection. What follows is what follows a lost answer: a replacement acquisition asks the same question under the same name and adopts the decision, or is told the run has moved past it. Nothing is retained for an interruption that mutated nothing: reading a source and offering scratch parts stay free of ambiguity, and the acquisition they were interrupted on stays usable. That distinction is now the grip's, rather than a single answered-once flag: one transition sends several commands, and a preliminary answer in the middle of a fork is not the end of the fork. The flag made it one, so a cancellation during the source read or the staging that followed left the acquisition guarded forever and a cancellation at the final commit retained nothing at all. A retained decision is re-observable on the terms the decision itself sets. An answer that carries a begun value grants execution authority, and that is returned only when the exact execution becomes this acquisition's — a ledger row that no longer names one is a disagreement, not authority. An answer that began nothing adopts nothing and invents no hold to go with it.
A run that is waiting has no executor, and handing it a value must not start one. So delivery is a third plane on the owner beside the executor socket and the read plane: it accepts no socket, mints no acquisition, moves no lifecycle, and can be answered while another executor is live. What separates it from the read plane is that it writes exactly one row — the value, correlated to the wait it answers — and nothing else. No journal event, no execution, no status, no root, no mapping. The value is judged before it is retained, and it is judged on the runner. A wait retained a response schema, and judging against it means compiling that schema with the secret scanner beside it; both are the document runtime's, and a run's owner is not a document runtime. So the owner says what the run is waiting at, the runner judges the offered value against exactly that, and the owner writes only after reading the same facts again inside the transaction that writes. The fingerprint is what makes that safe: the runner recomputes it from the description it was given and refuses an owner that names another, and the owner refuses a retention whose fingerprint is not what its retained request spells now. A value judged against one request can never be retained against a different one. Spending it is one commit. The execution that reaches the wait — the one standing at it, which is not something a row can decide — reads what the owner retained through its own acquisition, publishes the answer into the transaction it is already inside, and enlists the consumption beside it. The owner receives one proposal carrying both: it reads the row it retained, requires it pending and delivered against this request, requires exactly one appended event to be that wait's answer carrying exactly the value it holds, and then spends the row and appends the events. A runner cannot publish one value and spend the row for another, cannot spend without publishing, and cannot publish without spending; any disagreement refuses the whole commit, which leaves the answer pending and appends nothing. The value reaches the document only after that commit returns. An offered publication proves nothing — the transaction it was offered inside may not have committed — so what ends the wait is the owner having committed, and a later execution replays the recorded event rather than reading delivery state again. Three pieces moved to make this possible without a host reaching into another. The wait's two effect names and its identity now live in a leaf module, and the fingerprint is composed from the canonicalization and the SHA-256 this package already carries rather than from `node:crypto`, so a Durable Object can recognize a request and an answer without loading a document runtime. And the check for whether an execution is standing at its own wait is shared, because every host that ends a wait asks exactly that question and none of them may answer it differently.
…once Three places took a caller's word for something only the owner can decide. The delivery plane accepted a value beside identities the caller supplied, and retained it without ever asking whether the value satisfied the schema the wait published. A caller with delivery admission could read the wait, then offer any canonical JSON against it and leave durable state holding an answer the wait does not admit — one a later execution would publish and hand to authored control flow. The lower operation that made that possible is gone. There is one mutating delivery operation now, it carries the value and the gate decision and nothing else, and the owner resolves the wait, judges the value against the schema that wait retained, and applies the selected gate, all inside the transaction that writes. Judging it there needed a judgment that runs there. The compiler the document path uses builds validators with `new Function`, which a Worker refuses, so the judgment is written in the language itself and both sides run that exact module: the runner before it offers anything, and the owner before it writes. It is closed rather than lenient — every keyword it applies is listed, and a schema using anything else is refused rather than judged with that constraint quietly skipped, so a value it accepts is one every constraint its wait stated was actually checked against. A parity table holds it to the compiler's own verdicts across every schema it admits. The credential gate is the same shape and is honestly narrower: the full scanner needs a Node runtime the owner does not have, so the runner still runs it and the owner applies a floor over the same two framings. What the caller may no longer do is omit the choice. Reading the value took only a socket. Any admitted connection that knew a suspension identifier was handed the retained answer — including one whose execution had already settled, and one that had begun nothing — which discloses document input and lets a publication be built before anything owns the right to publish it. A claim now names the journal event the request was published as, and the owner requires the asking acquisition to hold an execution the run has not moved past. A settled, replaced or never-begun acquisition is told nothing. Publishing an answer was not tied to spending one. A commit could append a `suspension_answer` with no consumption at all, or append a second answer event beside the one its consumption authorized, and the owner would write both. The two are one act, so they are checked as one: an answer event with no consumption is refused, a consumption with anything other than exactly one answer event is refused, and a consumption from an acquisition holding no open execution is refused. Every check happens inside the owner's transaction before any durable mutation, so a refusal takes the whole proposal with it.
…hema check is the compiler
The gate a delivered answer crosses is the one durable journal persistence is
written through. The owner was applying a summary of it instead — a pattern
list, documented as a floor — and the difference was not theoretical: a
credential-named field carrying an opaque value passes the summary and is
refused by the configured scanner, so an authenticated caller reaching the
owner directly could retain a value the journal itself would not accept.
The owner now runs that scanner. It is the recommended preset and this
repository's own credential rule, over the same two framings the local host
scans, and it needed nothing but a resolution path that does not drag a
terminal renderer into a Worker — so `@executablemd/core/secrets` publishes the
gate the way `@executablemd/core/canonicalize` publishes the ordering. It runs
before the transaction, because it is asynchronous and a Durable Object
transaction cannot wait, and what it read is pinned by a request identity the
transaction requires to still be the retained one before it writes. A scanner
that cannot run at all is a refusal. The explicit opt-out is still the only way
past it, and the pattern list is gone rather than kept beside the real thing.
The schema check is a different story and this commit tells it plainly rather
than papering over it. The settled contract names the local host's compiler,
and that compiler builds validators with `new Function`: it performs four code
constructions to compile one schema, where the scanner performs none. A
deployed Worker does not generate code from strings, and the reason it works
under test is that the test pool proxies `globalThis.Function` into an unsafe
evaluation binding. So the owner cannot be that compiler without a capability
its deployment would have to grant, and this commit does not pretend otherwise:
every claim of equivalence is removed from the module, its tests and its
comments, and the disagreement is asserted instead — `{ multipleOf: 0.1 }`
accepts `0.3` at the owner and refuses it at the compiler. What the owner runs
is named for what it is, an additional check that refuses early and refuses
more, and the gap it leaves is reported rather than described as closed.
One gate is now proved to be one gate: a regression runs the scanner the
journal is written through and the gate the owner applies over the same content
and requires the same verdict, so a rule or configuration change cannot split
them again without saying so.
…t is judged
A response schema was decided by two different things. The document runtime
compiled it; a run's owner could not, because a Worker refuses code generation
during a request and a schema learned from a retained wait cannot be compiled
before the request that carries it. So the owner ran something else, and the two
disagreed — `{ multipleOf: 0.1 }` refused `0.3` on one side and accepted it on
the other. A value's verdict depended on where it happened to be judged.
Now there is one judgment and it generates no code, so the boundary that writes
a value is the boundary that judges it: `<Elicit>` and `xmd prompt` judging a
provider's answer, local delivery and artifact delivery judging a delivered one,
the remote client before it offers anything, and the owner inside the
transaction that retains it. The settled draft-07 verdict is the same at all of
them, `0.3` included.
Preparation still refuses everything it refused, and one thing it had stopped
refusing. `$async`, `__proto__` as a declared name, a reference that leaves the
supplied schema, and a keyword draft-07 does not define are all decided in one
walk, before a question is rendered or a provider is contacted — and the schema
is then validated against the draft-07 meta-schema, which is what the compiler
did and what the owner's own subset never could. `format` annotates and
constrains nothing, as it did.
The public schema surface comes back with it. The subset the owner had been
using refused `if`/`then`/`else`, `patternProperties`, `contains` and
`dependencies` as unjudgeable; those are draft-07 keywords a document may write,
and they are judged again. Props, returns, `<Parse>` and `<SafeParse>` keep the
compiler they had: they never run inside an owner, and nothing about them
changes.
Two assertions moved with the implementation, because failures now read in its
words rather than the previous compiler's. Neither was weakened: both still
require the issue to name the member that failed and what was expected there.
And `enum` and `const` failures are described rather than quoted, because the
old text listed the schema's own allowed values and an issue travels further
than the document that declared them.
…saying the value
Four things the shared judgment got wrong.
It deleted every key named `format`, everywhere, treating each object it met as
a schema. A `format` is an annotation and belongs nowhere in a decision, but a
literal under a `const` is data a document wants matched exactly, and a property
a document declares as `format` is a name rather than a keyword. So the exact
literal stopped matching, and the declared property was deleted and then refused
as one the schema never declared. The transform now walks the same positions the
schema walker already knew about: keywords are keywords, data is carried across
by value, declared names stay names, and the authored declaration is never
touched — a provider still sees the `format` that describes what to answer.
A reference whose target did not exist passed preparation and failed later, and
only if some value happened to reach that branch. Everything that makes a schema
unusable has to fail before a question is rendered and before a provider is
contacted, so every reference is now resolved when the schema is prepared, in
the same lookup the validator will use, at every schema position — including
branches nothing samples.
A value was judged by what the language answered for it rather than by what it
held: `{}` satisfied a schema requiring `toString`. What the validator is given
is now a private projection whose objects hold only their own names, so an
inherited name is absent, and absence is an ordinary reported failure rather
than something thrown from inside a dependency. What the caller keeps, binds,
retains and journals is untouched.
And a failure said too much and too little at once. Independent rules were being
dropped — a `minProperties` failure disappeared because a member failed too —
where only the wrapper of another failure should go. Locations came back as URI
fragments, so a member named `🐲` was reported as `/%F0%9F%90%B2` instead of a
JSON pointer. Messages were the dependency's, and quoted the rejected value and
the schema's own thresholds into something that is printed, bound into the
evaluation environment and carried across a journal. Each is fixed: independent
failures survive, pointers are raw, and every message is this repository's own,
naming the rule and never the value — except `required`, which names the member
that is missing, because the location cannot.
The deduplication key had a NUL byte in it, which is why Git showed the file as
binary. It is a serialized tuple now.
… else A completed run asked to run again answers from the terminal its journal records: it imports nothing, performs nothing and appends nothing. But canonical execution still has to be given a root document, and locally that value came out of Git because a checkout was there. A run whose durable owner is somewhere else has no checkout at all, and fetching one for a replay that imports nothing is live retrieval performed for a document nobody reads. So it comes from the run. `retainedReplay()` reads the root the run recorded — the retained `__root__` import, or the binding core writes into a terminal created before any import — and holds its path to the definition the run record retains, which is the half of the comparison that never came from the journal. The bundle is the same judgment made narrower: a completed replay is handed no component source and no authority to resolve a name, only the admission that holds every recorded import to the name, canonical path and object id the definition declares. A declared member the history never imported is neither read nor fetched. The shared CLI decides that from the frontier its owner answers with, right after admission, and therefore ahead of every live-only construction below it: the retained definition, this host's own adapter, the suspension controller, the answer provider, the `<Evaluate>` declaration and `host.attach()`. A lifecycle row claiming completion over a history that records no result, a history continuing past the result it records, a missing or malformed root import, and a retained root naming another document each refuse closed.
… it says Three corrections to completed replay, each about believing a record instead of establishing what it claims. **Recovery decides, not the status a run still carries.** An executor may commit its document result and disappear before settling, and the run then reads `running` while its journal already holds the outcome. The definition was fetched ahead of the lifecycle transaction that recognizes that, so a bundled run in the supported crash window went to a checkout an ephemeral runner may not have — on the one path required to reach only its durable owner. The definition is now fetched after admission or not at all: the begin transaction reconciles the stale execution, publishes the terminal its retained root result implies, and answers `replay`, and every live-only construction below it is downstream of that answer. A live or partial run still reconstructs and authenticates its whole bundle before any document effect, and a reconstruction it cannot perform leaves the journal and the Workspace root exactly as they were, with the envelope it began closed by the settled recovery at the next acquisition. **The lifecycle row and the recorded result have to be the same outcome.** A row claiming `completed` over a root `Close` that records a failure was accepted, so canonical core could reuse a terminal the run's own state contradicts. `retainedReplay()` now requires `rootOutcome()` — the settled mapping recovery and settlement already publish through — to agree with the retained status and stop reason, and refuses the pairings the lifecycle cannot produce before terminal reuse. **A retained component source is held to the object id, not merely beside it.** The replay admission required the history to repeat the declared hash, which establishes nothing about the bytes recorded next to it: altered source was admitted under the original identity. It now names those bytes the way Git names a blob — the kind, the encoded byte length, a NUL, the content — under the definition's own object format, and requires that name to be the object id the definition declares.
A `start` naming a run that has already ended is the same terminal reuse a `resume` of one is, and the begin transaction says so: it holds the supplied definition, base, props and bundle to the immutable record, and answers `replay`. The branch below it asked a second question — whether a candidate definition had been supplied — and a compatible `start --id` always has one. So the shipped compatible-reuse surface took the live path. It skipped the lifecycle/root agreement entirely, so a row saying `completed` over a root that raised was handed to canonical core, which returned the retained failure and left the invocation offering `failed` for a run the owner records as completed — two authoritative outcomes for one run. It also constructed the suspension controller, the answer provider and the live bundle execution view for an execution canonical core returns from before expansion. `replay` is now the whole question. What a caller established describes the request; what the run retained describes the result. The agreement judgment moves to `lifecycle/policy.ts`, beside the mappings it is derived from, and now accounts for both authorities that publish a terminal for one root Close. Recovery reads the coroutine's own settlement, so a document that *returned* a failure recovers as `completed`; the executor that watched it settles the same Close as `failed`, naming the row it failed at. Both are states this system produces. What no settled path produces — a failed row over a document that succeeded, a terminal row over a root that raised or was cancelled and says otherwise, a reason naming something the run does not hold — still refuses before terminal reuse.
… its own envelope Two corrections, both about a run being described twice. **The root Close says what the document did, not merely that it returned.** A durable Close has two layers: the coroutine's own settlement, and — when it settled by returning — the document result it returned. Reading only the outer layer called every finished document a completed one, so recovery published `completed` for a run whose document failed while an uninterrupted settlement published `failed`. The correction had then written that disagreement down as two authorities allowed to differ, and admitted whatever reason the second one carried: an unrelated successful row, an invented host code, or a terminal with no readable document result at all. `rootOutcome()` now reads the whole thing. A returned result decides `completed` or `failed`; an outer error or cancellation keeps its own meaning; and a returned value that is not a document result is `damaged` — refused before canonical core is handed it, rather than becoming an outcome nobody can account for. The failed reason comes from `retainedFailureReason()`, the one rule the live runner already used, which settlement, stale recovery and retained-history admission now all reach. A stale run whose document failed therefore recovers to exactly the `failed` status and reason an uninterrupted settlement publishes; `resume` then applies the settled failed-run refusal and a compatible `start --id` replays that same failure. **A replay closes its own envelope and nothing else.** The remote owner already skipped publication for a terminal run; the local adapter published unconditionally, so even a coherent replay rewrote the run's status, stop reason and `updated_at`. It now applies the same rule the owner does, decided from the retained state inside the same transaction and exact-lock checks.
…ion over it A retained root Close proves the document ended. When its form is not one canonical core writes, this build has no authority to say what it ended as — and the previous correction, having noticed that, then threw the fact away. `closingOutcome()` encoded damage as "interrupted, publishes nothing", both adapters read that as the stored `running` status, and their begins went on to insert another execution, publish `running` and hand the document back to a live invocation. The remote cancel lost it the same way and published `cancelled` over history it could not read, while the local one had grown a check of its own — two providers deciding one lifecycle differently. Damage is now a decision of its own. `Closing` carries it, both reconciliations return it without writing anything at all, and both begins and both cancels refuse with one shared sentence that repeats nothing the history held. The settled behaviour for the stale envelope is the same on both: it is not closed either, because what the execution became is exactly as unreadable as what the run became. The terminal judgment also got stricter in the two places it was loose. A `root_binding` is not decoration a failure may carry: core writes one in exactly one situation, and the whole pre-root form — the empty output, the segment that repeats the failure's own message, the closed path/source/target — is what makes an import-free history attributable to one document. Anything else wearing a binding is a terminal core did not write. And the terminal has to be the frontier: `terminalFrontier()` requires exactly one root Close, last, and both stale recovery and replay admission ask it, so recovery can no longer publish an outcome from the first of two results that replay would refuse whole.
… around it Two ways an unreadable or impossible retained history still got past the lifecycle. **A terminal row does not vouch for its own journal.** `closingOutcome()` answered the stored status before it looked at the damage, so a run already recorded as `completed` or `failed` came back from both reconciliations with no damaged marker at all, and both begins went on to insert a replay envelope and return `replay: true`. Only afterwards did admission refuse the malformed history — by which time durable execution state had already moved for history that authorizes no execution. Damage is now the first question `closingOutcome()` asks, before the stored status is consulted, so a damaged terminal refuses on both providers for every row it can sit under. **A terminal is only canonical in the history that surrounds it.** A `root_binding` is written by a run that failed before importing anything, and an ordinary document result by one that failed or finished after importing — so the terminal's own members cannot tell a genuine pre-root failure from an ordinary one wearing a binding, and could not tell an ordinary result recorded with no import from a real one. `rootOutcome()` now correlates the two: a bound failure is canonical only with no root import, and an ordinary result only with exactly one root import this coroutine recorded and settled. `rootImports()` is the one reading of that frontier, and retained replay builds its root from the same answer rather than scanning for one of its own. Both rules together say what a workflow run's journal is: a document execution's. The restart harness wrote neither half — no root import, and a plain string where the document result goes — and passed only because a settled `completed` row used to excuse the journal beneath it. It now records the run's own import and returns the result canonical execution returns, both durably, so a second process restores them rather than recording them again.
`rootImports()` filtered retained imports by the root coroutine, so a second event naming `import_component/__root__` recorded under a child was ignored rather than refused, and a settled root import was treated as sufficient without reading the selection it carried. Canonical `admitRootHistory()` gathers every event naming the root import first, then requires exactly one in total owned by the root coroutine, and parses it — so lifecycle recovery could publish `completed` or `failed` from a history canonical execution rejects, and retained replay accepted one it rejects too. Uniqueness is now asked of the name, not of the ownership: one shared judgment gathers every event that names the root import, requires exactly one total, requires the root to own it, and parses it into the selection replay rebuilds the document from. Retained replay reads that judgment instead of deciding readability again afterwards, so neither boundary can accept a history the other refuses.
The lifecycle's own reader checked field counts and primitive types, so a retained root import naming a target its document does not offer, spelled a way canonical encoding never spells it, or carrying a "failure" reduced to a selector was read as a complete selection. Canonical `readRootSelection()` does more than recognize a shape: it parses the retained markdown, resolves an exact target against that outline and requires the recorded target back, and re-derives a recorded failure through `findTarget()` requiring the kind, the matches and the whole catalog to agree. History the executor refuses could therefore publish an outcome, insert a replay envelope, and hand execution a selector taken from a forged record. So there is one parser again. It moves out of `execute.ts` into its own module with the reading primitives it needs, canonical execution admits partial histories through it exactly as before, and the lifecycle reaches it through `@executablemd/core/host` — the same reasoning that already carries the Prompt record across that boundary. What comes back is the parser's own copy of the record, so the document a replay is built from is never the object the journal still holds. A selection outcome also has to be able to reach the terminal beside it. A recorded selection failure is raised out of the root import, so the document never ran: a successful result over one is two histories, and the run is damaged rather than completed. Two fixtures recorded selections canonical execution cannot produce — an exact target in a document offering none, and a failure carrying a catalog that document does not have — and now record what the same selector really decides.
The documents still called the internal remote lifecycle, transport, fork, inspection and completed replay unbuilt, and still said the neutral transition types were waiting to be exported. All of it is implemented behind the same four-method host, so the inventories now say what is built and, separately, that nothing yet assembles a configured public client, selector, endpoint, release-identity or OIDC supply — which is the whole of what remains before a caller could choose that host. A finished run also has a contract now, in one place. `specs/workflow-spec.md` §9.9 states what a retained terminal is and what may be concluded from it: one final root `Close`, two layers that each decide something, exactly one root import owned by the root coroutine and parsed by canonical execution's own parser against the document it recorded, damage outranking the stored row, and a coherent replay that changes nothing but the execution envelope it opened. The architecture and the Workspace specification reach that statement rather than restating it, and Tier WRH is no longer described as scenarios without tests: WRH1-WRH16 name the committed suites that prove them, and WRH17-WRH22 stay with the machine-wait work they belong to. Documentation only. No production module, test, dependency, lockfile, generated artifact, export or public API changes here.
… them The owner had three planes and no way in. It has one now: a path names the plane, one run id selects the object arithmetically, and a gateway forwards on that id alone — it parses no command, verifies no token and commits nothing, because an owner that trusted a gateway about any of those would have moved its own admission outside itself. The executor plane performs a real upgrade, checks the release before the token and the token before the run, and takes the acquisition last. A runner reaches all three through one configured client: an already-selected run id, one credential-free endpoint parsed before anything is sent, the exact release identity, a token minted for the immediate request, and the HTTP and WebSocket I/O its host performs. It is bound to that one run and refuses another before a token exists. Trusted code assembles it into the same four methods the local entrypoint installs — nothing selects it, reads a flag or an environment variable for it, or deploys it. Composing that exposed a real gap in the runner's Workspace coordinator. Its routed journal was not the handle's journal, so a document run through the CLI would have published its effects beside the transaction that made them and the provenance check would have refused every one. The remote handle now routes its own journal and records the provenance taken over it, the way the local handle always has, and the coordinator opens the run from the handle its own lifecycle produced rather than a second one. That makes the attachment exact. A handle remembers the link it was opened from; attaching compares that link, by identity, to the ones this runner's own acquisitions produced. Two clients on two owners can hold handles that agree about run id, root and anchor, so nothing a handle says about itself could settle which run it is — where it came from can. Two suites found real defects while proving this: a token was minted for a run the client is not bound to, and admission refusals were read through the command vocabulary, so a release or token category raised a malformed answer instead of an unavailability. Both are fixed here.
The fourth method installed the Workspace coordinator and stopped. So an authored `<File>` on a remote run resolved through whatever Files provider was in scope — a runner has the ambient host one installed by its entrypoint — and a document wrote to the runner's own filesystem instead of the run's Workspace. The new regression reproduces exactly that against the previous revision. An attachment now installs what a live or partial document reaches: the logical Workspace working directory, the document filesystem, the Repository, Worktree and `<Dir>` composition, transactional Git, the composition components, the pull-request and Issue middleware, elicitation, and whatever Agent profile its host configured. The set is inseparable — the Files provider alone would resolve authored paths against the surrounding host's working directory — and it is one set: `withDocumentCapabilities()` is what both hosts install, so there is one account of what `<File>`, `<Repository>` and the Git components mean. What a host contributes to those rules is two things. The effect a mutation becomes, and the savepoint that undoes a part of one mutation which could not be finished. Locally that is the lease it validated and a real transaction savepoint; on a runner it is the exact remote run its own acquisition opened, and an attempt restored from the accepted root — correct because one effect performs one mutation. Which of the two answers is decided by the exact storage handle. An attachment registers its binding under the handle it was given and removes it when the attachment ends, so a handle another client opened finds that client's binding or none, a handle nobody attached is refused rather than performed, and no context value is what says a filesystem, journal or publication target belongs to this run.
…al store Two capabilities were assembled remotely in name only. A Repository or Worktree committed its identity and its content through the run's own binding and then reattached its checkout through `transactWorkspaceRoots`, whose implementation is the local storage provider's private workspace — which a runner never installs. An Agent profile read and committed its session mapping through the same door. So a remote run could publish a checkout it could not then attach, and establish a conversation whose mapping nothing retained. A host now answers four questions for one run rather than two: the effect a mutation becomes, the savepoint that undoes an unfinishable part of one, the read an ephemeral attachment needs, and the transaction a session mapping is retained by. Locally all four come from the lease this handle already validated. Remotely the read takes one coherent owner snapshot, materializes the selected root into a directory the invocation owns, hands the attachment only the retained metadata and that filesystem, and closes before native Git runs — no owner read and no transaction held across a subprocess. The mapping transaction stages from the admitted state on the runner, where the provider is, and submits bounded deltas to one owner transaction that revalidates each subject and commits them all or refuses them together. No provider call happens inside it, a body that failed or was cancelled sends nothing, and a conflicting assertion never replaces what is retained. A handle binds its host when it is opened, not only when a document is attached: reading a retained mapping needs no Workspace, and a caller that opened storage alone has always been able to. An attachment binds a narrower one over the top and gives the outer one back when it ends. The public configuration is the published one. `capabilities` on the runner and on the configured host accept what a *host* owns — the issue tracker, the readable pull requests, the credential helper, the Agent profile — and no substituted repository host, Git-host transport or invocation observer, each of which is a seam a credential this run acquires would become visible through. One projection does that for both, member by member, and a type-level regression fails if the seam comes back.
… run Both capabilities were argued from below rather than executed. The Repository evidence preloaded a record and called the attachment read directly; the Agent evidence called the session transaction directly, against an owner whose later snapshots never included the mapping it had just accepted. Either one could pass while the composed path failed, which is exactly what happened twice before. So the scripted owner now adopts what it performs. A commit that is performed moves the pointer to the root it published, keeps the bytes it was staged, and merges the mappings it validated — so a later coherent snapshot answers with the run as it became, and a reattachment or a continuation reads what was actually retained. On that owner, one authored document through the configured public host clones a real repository on the runner, retains the checkout and its immutable identity in one owner transaction, reattaches it, and switches its branch in a second transaction that starts from the root the first one published. The remote is then deleted and the same document runs again: it reconstructs the checkout from the committed frontier, retains no second Repository, and reads the branch's own file back. The retained record names the checkout by its logical Workspace path and the remote by a fingerprint — no locator, no host path, no transport detail. The Agent path is driven through `capabilities.agent` on the configured host, with the shipped policy and the shipped transaction and only the provider standing in. It establishes, commits and then prompts, in that order; a later attachment reattaches the exact retained assertion and creates nothing; a failure before the commit retains no mapping; and a different conversation under the same identity is refused before any replacement. The contract inventory says which providers each capability has now, without implying that anything selects the remote one.
The two D5 regressions asserted structure they did not reach. The scripted owner discarded every event it accepted, so a mapping and a root sat beside an empty journal — a state no owner can produce from a successful effect. It now retains the ordered journal: each entry with the id the owner minted for it and the Workspace root the transaction that appended it selected, appended in the same step that moves the root and merges the mappings. `frontier` and the mappings snapshot name the terminal event, and a `journal` read answers one anchored page at a time from exactly where the client says it is. So the Repository regression can be about continuation. The first execution clones, retains the creation whole, and is cancelled with the Git mutation's proposal still in flight: the owner decided the creation and never decided the mutation, which is a prefix an owner can be holding. The continuation reads that prefix back in anchored pages, restores the recorded creation with no remote left to clone from, rebuilds the checkout from the root the replayed record selected — the live switch starts from that root and moves a checkout really on `main` — and performs only the work the cancellation left undone. The Agent regression installed its own policy. It now installs the shipped `useWorkflowAgentProfile()` through the public configuration and runs an authored Agent/Session/Prompt document, with only the agent process and its own session store substituted. Sampled at the owner, the order is the contract: the conversation exists, the owner then accepts which one it is, and only then does anything prompt it. A restart reattaches that exact provider-native session and prompts only the unfinished turn; a completed document restores without a provider at all; an establishment that fails and one cancelled in flight propose no mapping; and a provider asserting a different conversation is refused before a replacement session or a prompt.
Two assertions could not fail for the reason they were written. The replay check looked only at commits that published. A Repository effect that ran a second time while the owner already held a compatible mapping would neither clone nor publish a root: it would return that mapping and append its own event, in a commit that publishes nothing — exactly the shape the check had filtered away first. It now reads every commit the continuation made, and what the owner ends up holding: no `workspace_repository` event and no Repository mapping after the prefix, and one Repository effect row in the whole journal, the one already in it. The conflict case transplanted a mapping into a second owner that had never committed it and held neither the root nor the journal of the transaction that did. It now runs on one owner: the session is established through the shipped profile, the execution is cancelled with its first Prompt in flight, and the provider then comes back holding a different conversation under the same placement. Nothing else changes. The continuation refuses before a replacement session exists — the provider is never started — before anything is prompted, and before any commit at all, so the mapping, the root and the journal are the ones the interrupted run left. The refusal arrives as the cause of the divergence that stopping before a retained history is, so the assertions read the whole cause chain rather than the outermost wrapper. Beside them, the scripted owner answers a commit with the identities that commit minted rather than with a slice computed from the journal's length, and a mappings-only intent is now shown being answered with none. Its paged journal is capped at the anchor it was asked for and refuses an anchor it never minted or a cursor outside that prefix, which the Repository case reads back directly once the journal has run past the prefix its continuation consumed.
The anchored journal contract admits a cursor only strictly before the anchor: `readJournalPage()` refuses `afterSequence >= anchorSequence`, because a cursor at the end of a snapshot is a reader asking for a page after its own snapshot ends. The scripted owner refused only a cursor past the anchor and answered one at the anchor with an empty successful page, and the direct read that checks the anchored prefix recorded that answer as valid. So a client that asked for another page after reaching its terminal event could have passed this suite while the owner it will actually talk to refused. Both cursors are now the same refusal, and the test expects it. Nothing else moves: a null cursor still starts at the first entry, a cursor strictly before the anchor still continues after it, the page is still capped at the anchor however far the journal has run since, and an anchor this owner never minted is still refused as one.
`local/no-redundant-test-scope` refuses a `scoped()` call that wraps a whole `it()` body: the BDD adapter already runs each test in its own scope, so such a wrapper opens a lifetime that begins and ends exactly where the test's own does, and leaves a reader two candidate owners for the test's resources. Twelve of them were in the runner's Workspace suite. This is the rule's own fix — each body dedented one level, nothing else moved — applied because the rule is only reachable through the full `deno task lint`, which runs its JS plugin, and the focused per-file checks that carried these commits never loaded it.
… takes them `Tier DLC — DLC13` holds the provider-neutral Workspace coordination surface to a vocabulary no host owns, and a savepoint is one host's answer rather than the question: it is what SQLite offers, and naming a shared contract after it is how a neutral boundary quietly becomes one provider's. The guard caught two files doing exactly that — `src/workspace/savepoint.ts`, whose Api member, context identity and refusal text all said savepoint, and `src/remote/workspace.ts`, which implemented that member. So the contract now asks for what it wants: `Transaction.undoable(body)`, in `src/workspace/undoable.ts`. Nothing else changes. The local host still nests a real SQLite savepoint, and the module it installs from re-exports the operation under that name, so its own call sites read as they always did. The runner still restores its disposable attempt from the accepted root. No behavior, retained state, durable identity or authority moves; the failing name does. The guard only runs in the full battery, which is why the D-phase focused evidence never reached it.
Discovery puts every new `*.test.ts` in all three runtime jobs, so staying out of one is a deliberate act this file records. Two of the remote suites never made that act, and the Node job failed on them exactly as the file predicts: `createWorkflowRun` takes a run's advisory lock through `Deno.FsFile.tryLock`, and no Deno runtime is present. Both belong to the local host by subject rather than by accident. The interoperability suite exists to show the two capture implementations agree, so its fixture has to be produced by the local one — a real `node:sqlite` store, a run created through the Deno provider, a DOFS capture — before the runner materializes and recaptures it. The staged fork suite assembles its candidate through the Deno adapter's own connections, staging directory and lifecycle installation. Neither claim survives being made portable, and the portable halves of both are already elsewhere: `remote-fork.test.ts` for what a staged candidate must be over the no-acquisition plane, and every other Tier WRH suite for what the runner does. Node now reports 4442 passing and none failing.
`deno task test:cloudflare` failed with "Vitest failed to find the runner" and collected no tests at all. The cause is two installs: the Workers plugin imports `vitest` and resolves pnpm's copy, while `node_modules/.bin/vitest` is whatever wrote that link last — and a `deno task` re-materializes `node_modules/.bin` into Deno's layout before it runs anything, so in any tree that `deno task setup` prepared, the CLI and the plugin are different vitest instances and the pool never takes over. Naming the module instead of the link settles it: `node ./node_modules/vitest/vitest.mjs` is the same copy the plugin resolves, whichever installer touched `.bin` last. CI already ran this script the one way that happened to work — `pnpm test:cloudflare`, with no Deno layout in the job at all — and still does; what changes is that the alias beside it works too, on the tree a contributor actually has. `deno task test:cloudflare`: 14 files, 196 tests, 0 failures.
minkimcello
marked this pull request as ready for review
September 11, 2026 01:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A workflow run today needs one machine that holds everything: the SQLite store, the Workspace, the journal, the executor lock, and the tools. That is fine for a laptop and impossible for a GitHub Actions job, which is ephemeral by construction — it can disappear mid-run, and nothing about the run survives it.
#698 is F1 of the software-factory stack: it separates the two halves that were only ever together by accident. Authority and retained state move to one durable owner per run; native work stays where the tools are, on a runner that may vanish at any moment.
What changes
Before: a run's storage, authority and execution were one Deno host over a local directory. A runner that died mid-flight took the run with it, and nothing else could resume it.
After: a run can be owned by one SQLite-backed Cloudflare Durable Object, selected from the public run id by the same arithmetic local discovery uses, while an ephemeral runner performs the native work and submits complete validated transactions. A runner that dies exposes only a prior or a new complete transaction, never a partial one; the next acquisition performs the ordinary stale-execution recovery and resumes from the exact committed WorkflowRun and Workspace frontier. Everything a document can write behaves identically on either host.
Nothing selects the remote host. The shipped entrypoints are local, and trusted code assembles the remote one explicitly from configuration it supplies itself.
How it works
The path from a document to that:
useRemoteWorkflowHost()(trusted CLI assembly) →useRemoteWorkflowRunner()(the fourWorkflowHostmethods over a configured client) →remoteOwnerClient()(one client bound to one run) → the three planes →WorkflowOwnerObject(the Durable Object).Review guide
Start with:
architecture.md, "One remote owner for one run" — the whole contract in four paragraphs — thenspecs/workflow-workspace-spec.md§13.2.Then review:
packages/workflow/src/cloudflare/owner.tsandowner-reads.ts— what the owner is, and what it refuses.packages/workflow/src/remote/client.tsandsrc/cloudflare/routes.ts— the three planes and the private message shapes, which are one release talking to itself rather than a public wire contract.packages/workflow/src/deno/remote-runner.ts— the runner assembling the same four methods over that client.packages/cli/src/remote-workflow.ts— the configured public host, and the narrow capability projection it applies.packages/workflow/src/remote/workspace.tsandsrc/workspace/effects.ts— Workspace work routed by the exact storage handle.Look carefully at:
What must stay true
admitRunId/ownerFor, checked byremote-owner-routes.vitest.ts.remoteOwnerClient(), checked byremote-workflow-host.test.ts.remote-publish.vitest.tsandremote-workspace.test.ts.remote-workflow-host.test.ts.Tier DLC — DLC13andhost-neutrality.test.ts.specs/workflow-workspace-spec.md§13.2 and true of the diff.How to verify it
packages/workflow/tests/cloudflare/*.vitest.ts(14 files, 196 tests) run against a real Durable Object namespace, real SQLite storage, a real WebSocket and real eviction. They catch anything a model of workerd would hide: acquisition outliving a hibernation, a transaction that is not atomic, an owner that answers after eviction.packages/cli/tests/remote-workflow-host.test.tsdrives the configured public host end to end: an authored<Repository>/<Git.Switch>/<File>document that is cancelled with its Git proposal in flight and then continued from the retained prefix, and an authored<Agent>/<Session>/<Prompt>document through the shipped Agent profile. It catches a continuation that re-runs durable work, a mapping committed after the first prompt, a reattachment that creates a second conversation, and a conflicting assertion that replaces one.packages/workflow/tests/remote-runner.test.tsproves the runner attaches the handle its own lifecycle opened and no other, and that a cancelled attachment proposes nothing.packages/workflow/tests/remote-interoperability.test.tsproves the local and runner capture implementations produce the same root identity for the same tree — without it, a run would change its Workspace by moving between hosts.packages/workflow/tests/host-neutrality.test.tsandTier DLC — DLC13catch a shared module that imports a host entrypoint, detects a runtime, or borrows one host's vocabulary.Manual:
deno task test:cloudflareruns the workerd suite;deno task test packages/cli/tests/remote-workflow-host.test.ts packages/workflow/tests/remote-runner.test.tsruns the two host suites.Scope
Included
WorkflowHostmethods.test-cloudflareCI job, and the*.vitest.tssuffix that keeps those files out of the Deno, Node and Bun corpora.Intentionally unchanged
wrangler.jsoncis the test worker's.New abstractions
WorkspaceHostBinding(create/read/sessions) exists because a document reaches exactly three kinds of Workspace work, and both hosts must answer all three behind the exact storage handle. Two concrete implementations.Transaction.undoable(body)exists because the shared Files and composition rules must be able to undo part of one mutation without knowing which host performs it — a SQLite savepoint locally, an attempt restored from the accepted root on the runner.RemoteWorkflowConfigurationexists because a trusted host must supply run id, endpoint, release, token and capabilities explicitly, and nothing may infer them.New dependencies
@cloudflare/vitest-plugin@1.1.3,@cloudflare/workers-types@^5.20260831.1,vitest@4.1.11(with@vitest/runnerand@vitest/snapshotpinned to match),typescript@^5— used only to run and typecheck the workerd suite. Existing runners cannot help: acquisition lifetime, owner eviction and transaction atomicity are properties of workerd, and neither Deno's test runner nor Node's can start one.Generated or mechanical changes
pnpm-lock.yamlfollows the dependency additions above.package.jsonanddeno.jsoncarry cosmetic rewrites an older pnpm made during development:descriptionre-encoded with a—escape,effectionreordered among the dependencies, anddeno.json'sworkspacearray expanded across lines. Inert, and no behaviour change is intended by any of them.packages/workflow/tests/remote-workspace.test.tswas dedented one level in twelve places bylocal/no-redundant-test-scope's own fixer; no assertion changed.Delivery status
b6c127f9944c0414a661767adfdc94941ab04ccb.verify:cleanpassed setup and every offline build/compile/resolution phase, then its Bun interference participant could not start. Required CI remains authoritative for Bun and the final delivery gate.Dependency
GitHub base: PR #736 has a fork-only head, so this PR correctly targets its upstream base,
codex/github-actions-software-factory-architecture. Until #736 merges, GitHub includes the prerequisite commits in this comparison; the diff contracts to #698 after #736 lands.This branch is stacked directly on #710 / PR #736 at
817d3dd36cb07fddd972c6ae33716444f3cb75da, which is not inmain.origin/mainhas advanced 33 commits past the merge base the two share, so a diff taken against currentmainwould both attribute unrelated commits to this PR and hide that it depends on unmerged work. Review it against817d3dd…, and merge it only after #736.Risks and limitations
deno task testby file suffix and runs in its own CI job.bunbinary on the auditing machine — and is covered by CI'stest-bunjob.Scope confirmation