🏭 Build remote workflow provider checkpoints D1–D3 (#698) - #1
Closed
minkimcello wants to merge 42 commits into
Closed
🏭 Build remote workflow provider checkpoints D1–D3 (#698)#1minkimcello wants to merge 42 commits into
minkimcello wants to merge 42 commits into
Conversation
) 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
Three ways the owner could publish something nobody proposed.
A journal record was admitted for being a non-empty string, and the database
proved only that it was JSON. `{}` could therefore be retained atomically and
reported performed, and a later read — which parses events properly — would
find history it cannot understand. The run would become unreplayable at the
moment it was told it had committed. Every record is now parsed with the
authoritative parser and serialized again, and must be the same bytes that
arrived, terminating newline included. A nearly-right record is refused rather
than normalized, because retaining a corrected one would retain something the
runner never proposed.
A repository mapping carried only its journal-safe record, so the fingerprint
was written where the locator belongs and the run could not reattach to the
repository it claimed to have retained. The mapping now carries the admitted
locator beside the record, and the fingerprint must follow from it. Existing
rows are compared on every field that establishes creation identity rather than
a convenient subset — a partial comparison reported performed for proposals
that disagreed with what an earlier execution established.
A Repository or Worktree row names a checkout, and the row is only true if the
Workspace this commit selects contains it. A mapping-only commit could retain a
claim about a directory nothing put there, and the next execution would find the
claim and not the files. New checkout mappings must now accompany the
publication that creates them, a Worktree must name a Repository that exists or
arrives with it, and one proposal naming one mapping twice is refused.
And the starting root was taken from the pointer without being proved. A
journal-only commit could append history against a current root whose content
graph cannot be materialized — accepting a frontier nothing can restore. The
same validator the reads use now proves it inside the commit transaction, and
authoritative content is confirmed against its companion metadata rather than
trusted and then preserved by an `ON CONFLICT DO NOTHING`. A proposal is not a
licence to repair damage.
Two things surfaced while proving it. Refusals that are answers about the
request — a moved frontier, a duplicate id, a mapping conflict, a command this
release does not implement — no longer close the connection; only a broken
channel or damaged store does, because closing on an ordinary disagreement made
the runner reconnect to be told the same thing. And the workerd tests that
walked several object round trips were using sockets made inside the object,
which do not survive it being reset; they now use real accepted connections,
which is what the claims were always about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…#698) Three ways the owner could retain something it had not actually agreed to. A repository locator was admitted for having a fingerprint that matched itself. That says the two values agree with each other and nothing about whether the locator is one this system would ever hand to Git — so an authenticated proposal could retain a credential-bearing URL, an executable transport form, a query carrying a token, or a relative path, each with a perfectly consistent fingerprint. The closed allowlist the local host applies before Git sees a locator is now shared and applied by the owner before the fingerprint is compared at all. It is an allowlist rather than a search for bad shapes because Git's locator grammar reaches well past URLs, and a second copy of an allowlist is the copy that ends up longer. A Worktree's parent was looked for while its own row was being written, so `[repository, worktree]` committed and `[worktree, repository]` did not — the same transaction, refused for the order its mappings happened to arrive in. Identity, duplication, parent relationships and checkout placement are properties of the proposal rather than of one mapping, so they are all settled against the whole collection before anything is written, and application then runs parents before children. Which one a proposal lists first is the owner's problem, not something a runner should have to arrange to suit a schema. And a blob's metadata row with no bytes beside it was treated as absent rather than as damaged, so staging would supply the missing bytes and a proposal would quietly complete a half-written identity. Which durable state won then depended on the write path instead of on what the store actually holds. A partial identity is now storage corruption, refused before staging is consulted. The evidence is on real workerd: five locator forms that must never be retained, a positive round trip proving the row holds the repository rather than a digest of it, a Repository and its Worktree accepted in both orders with the same result, and a metadata row without its bytes refusing without repairing anything or recording a decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The owner could decide a proposal and nothing could make one. `CommitIntent` carried the frontier and the events; the link refused every commit by construction; and the trees a mutation happens in were owned by a test helper. That is the half of D3a that makes the other half reachable. A transaction now carries what it decided: the expected frontier, the events, optionally one complete Workspace publication, and the retained mappings the same operation produced. Enlisting is a private capability handed to the body rather than something reachable from the database, so work that never received it cannot publish a Workspace by accident, and a second enlistment is refused — two Workspaces proposed for one commit is a choice nobody may make on the run's behalf. What is enlisted is detached on the way in: the caller still holds its arrays and may go on editing them, and the proposal the identity was computed over must not change underneath it. The Cloudflare link stages the pieces the owner does not already hold, encodes one closed command, and reads the decision. Content is addressed by what it is, so a Workspace the owner never lost is not sent again. The command identity is minted once per intent and reused verbatim if the answer is lost — the owner recognizes a retry by that identity, and regenerating one would ask a second question rather than the same question again. A lost answer returns undecided rather than failed, because whether the owner committed is exactly what cannot be known from the runner. Two trees, and the difference is the point. The materialization is the accepted root: what the owner last confirmed, restored so tools can work in it. The attempt is where a mutation happens, and it is disposable by construction. A documented failure throws the attempt away and leaves the accepted tree exactly as the owner confirmed it; working directly in the accepted tree would mean a failed effect had already changed the only local copy of the run's Workspace. Both are resources, so a raised failure, a cancellation and a refused commit all leave nothing behind, and only a performed owner answer promotes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The production-path test carried a dead helper, two unused bindings, two redundant scopes and a chain of assertions that told the compiler what the request was rather than checking it. What is being proved is the shape of what the runner sent, so it is read through narrowing helpers that fail with the member that disagreed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…#698) The local host walks the content tables inside its own SQLite file; the runner walks a real directory. Neither walk can be shared, and a root identity is a digest of what the walk produced — so if the two ever disagreed, a run would change its Workspace by moving between hosts and every no-op remote effect would propose a root the local host had never seen. Until now the round trip proved only that the runner agreed with itself. The fixture is now the local provider's own capture. A real run database is opened, the discriminating tree is written through the authoritative Workspace transaction, and `capture({ publish: true })` retains the root exactly as a run does. That root and the content rows it closes over are served over the remote read boundary, materialized by the production runner adapter, and captured again by the runner's implementation. The two identities have to be one string. It found the disagreement it was written to find. The retained format carries modification times in milliseconds, because the retaining host's clock is `Date.now`; the runner adapter reported seconds. Every retained tree would have looked like a different one, and writing a millisecond value through an interface that speaks seconds would have put each file tens of thousands of years from now, where the filesystem cannot keep it. The adapter now preserves the unit the format uses and converts only where the host primitive requires it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…#698) Completing the discriminating regressions `698-e-6.md` asked for surfaced two defects in what had just been built. The command identity was counted rather than derived. The owner recognizes a retry by that identity and returns the decision it already made — so an identity that changed between attempts would ask a second question, and the owner would apply the proposal twice. It is now a digest of the exact bytes being sent: two attempts at one proposal share an identity, two different proposals cannot, and a retry is byte-equivalent. That retry necessarily happens on a new connection, because the one that lost the answer is gone and a connection refuses to reuse a correlation id of its own; the identity is stable across connections, which is where it has to be. And the body ran through `call()`, which let a resource whose teardown failed surface its failure after the commit had already gone out. The comment above it claimed the opposite. The body now runs in a scope closed before the intent is built, so "no commit was sent" and "the body did not finish" really are one statement — the one ordering here that cannot be taken back. The rest of the list is now covered: a journal-only commit proposing nothing and staging nothing, all three retained mapping kinds encoded with the locator only where one belongs, a refused answer and a lost answer each promoting nothing and leaving no attempt tree, and a transaction past its local bound sending nothing. The materialization suite uses the production trees resource rather than a helper of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Three ways a committed run could fail to converge. A mutation's decision lived in the acquisition-scoped ledger, and a replacement acquisition discards its predecessor's scratch. That is right for staged bytes and read decisions, which belong to the connection that produced them, and wrong for the fact that a mutation was applied — which is the one thing the next connection needs to tell "this already happened" from "this never happened". So the exact case the retry exists for was the case it could not answer: if the commit landed, the same request met a moved frontier and was refused as stale; if it did not land, the same request performed; and the runner could not tell those histories apart, so it could not know whether to promote or discard. Mutation decisions are now keyed by the run, cleanup never touches them, and a reused identity with different bytes is still a conflict. A decision is not a lease and does not expire because a socket did. A performed answer was read for syntax and thrown away. The adapter accepted an answer naming any root — including the root the run was already at while the proposal published a different one — and returned success. The answer is now checked against what was asked: the root the proposal selected, and one event identity for each event sent. An owner agreeing to something else is not an owner this runner can go on talking to. And promotion took no evidence and moved no bytes. Any holder could call it before a commit, after a refusal or after an ambiguous loss, and it relabelled the accepted tree with the new root while the promoted tree stayed unreachable — so the invocation would read the Workspace it used to be at under the name of the one it is now at. Promotion now requires the owner's performed decision, and that decision has to name the root this attempt actually captured. The attempt's own tree becomes the accepted materialization, the accepted path answers with the promoted bytes, and the tree the run used to be at is removed. Sealing also reached further than it looked. A shallow copy left an Agent session's provider assertion shared with the caller, and the content bytes were fetched through a callback at staging time rather than captured at enlistment — so a caller could change the retained session identity, or the bytes staged under a digest, after the transaction had sealed. Mappings are now copied all the way down and the bytes travel with the intent, checked against the identity they were proposed under before any stage request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…aras#698) Promotion took a value shaped like the owner's decision, and any caller could write one down. Comparing it against the attempt's own capture caught a wrong root and nothing else: a fabricated decision naming the right root promoted just as well as the real thing. The accepted materialization also carried a public `replace()`, so anything that could create an attempt could move the run to a Workspace the owner had never selected. And the one operation that did receive the authoritative answer — the transaction itself — discarded it, so completing an owner-authorized promotion meant leaving the supported path and repeating the protocol by hand. The authority is no longer data. An attempt offers a proposal, and that proposal carries a way back to the attempt that nothing else can construct; the transaction sends it, validates the answer against what it sent, and then transfers the tree itself, before it reports success. A caller may still build an enlistment by hand — it simply carries no attempt, so there is nothing to transfer, which is the right outcome rather than a missing step. `promote()` and `replace()` are gone from every surface a caller holds. What is left on the materialization is where a path is and which root it is; what is left on an attempt is where it is, what it captured, and how to offer it. The capability that moves the accepted tree is a symbol only this module can write, so a value that did not come from `useMaterialization()` cannot be mistaken for one. The evidence follows the production path end to end: mutate an attempt, enlist its capture, return from the body, and find that by the time the transaction reported success the accepted path had become the attempt's tree and read the attempted bytes, while the old tree was gone. The supported surfaces are enumerated and shown to expose no replacement or promotion, and a value naming the exact proposed root is handed to the only thing that takes an enlistment and moves nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The capability moved again instead of going away. `propose()` handed back an object with a callable `transfer`, so the same fabricated decision that used to promote through `attempt.promote(decision)` now promoted through `proposal.transfer(decision)` — before any commit, comparing only a caller-supplied root against a caller-held capture. The regression that was supposed to catch this built a *different* enlistment without that member, so it never touched the bypass at all. Two more things followed from taking a capture as an argument. The transfer moved the live directory while the identity came from whatever capture was passed, so a body could enlist, keep writing, and have the owner commit one root while the runner labelled different bytes with it. And a hand-built publication with no attempt behind it committed happily: the owner advanced and the invocation stayed on its old tree. So `enlist` takes the attempt. Not a proposal, not a capture — the attempt itself. The transaction seals it after the body and everything it started have finished, which means the proposal is the tree as it finally is rather than as it was at some earlier moment; the same seal produces the transfer, and the transaction calls it after validating the owner's answer. A publication that no live attempt owns is now unrepresentable rather than merely refused. What a caller holds is a place to work and a way to read what it did. Sealing and transfer hang off a symbol in a module no entrypoint reaches, so they are not on the declared surface and cannot be written down by anything that does not already have them. The evidence follows: enlist an attempt, keep writing to it, and the committed root, the transferred tree and the recaptured accepted root are one root and one set of bytes — the final ones. The supported surfaces are enumerated and carry no promote, transfer, replace or accept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The interface a run's storage answers is the same wherever the run lives. This is that handle, backed by a connection: snapshots that stay snapshots, a journal that reads fresh and appends through the one commit path, a caller-owned transaction, retrieval replacement, and an anchored execution list. Two mechanisms keep one handle in order because they answer different questions. A turn stops two operations interleaving, so unrelated work waits and then proceeds. A scope-local marker records that this scope is *inside* a transaction on this handle, so a nested transaction or an ordinary operation called from the body is refused at once rather than queued behind a turn its own caller is holding and will not release. A queue alone deadlocks that; a flag alone mistakes unrelated work for nested work. Retrieval replacement is its own mutation, not a degenerate commit. It appends nothing and publishes nothing, and its revision is the owner's arithmetic over what is stored — two handles that both read revision one before either wrote would otherwise both write two and the second would lose the first. Clearing removes the row, so the next replacement starts counting again. The identity is minted per invocation rather than derived from the request, because two calls carrying identical metadata are two replacements and must not collapse into one; and because a decision now names its command kind, one textual id used for a commit and for a replacement is two requests rather than a recognized retry. Executions read as one anchored snapshot for the same reason the journal does: a caller assembling a list across several requests must see one moment rather than whatever the table held at each of them. The first page fixes the terminal row and every later page is held to it, with the run identity and the sequence travelling privately so the runner can refuse another run's page and prove adjacency — neither becomes part of the record it returns. Failures cross as the categories the local host reports for the same condition. Command names, refusal spellings, rows and cursors stay below the adapter; they describe a protocol nobody above it is party to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…ras#698) The runner had no way to learn which snapshot it was reading. It sent the first page with no anchor, and the owner treated that as a claim the run had no executions — true only for an empty run, and storage damage for every other, so a run with any history could not read its own list at all. The owner chooses the anchor now. A first request carries none, the owner fixes the terminal row at that moment and answers with it, and every later page is held to what the first one chose. An empty run answers with an explicit empty anchor, which is a different answer from a terminal page and is read as one. The evidence is the case that exposed it: 129 executions, a first page that returns 128 rows and the anchor it selected, another execution begun while the read is in flight, and a final page that ends exactly at the anchor without it. Also proves the retrieval mutation on real storage: revisions counted by the owner, byte-identical metadata under two identities counted as two replacements, clearing removing the row so the next one starts again, one invocation retried across a lost answer and eviction applying once, the same identity with different content refused, and a replacement against a root the run has left refused without touching the row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…aras#698) Three ways the remote database could accept or report something it should not. An execution page was believed on very little. The run identity was required to be present and never compared, so another run's retained history passed. Rows had only to increase, so a gap, a first row that was not the first, or a row past the anchor all passed — and a page could call itself terminal while stopping short of the anchor, which turned an incomplete history into a complete-looking answer. There is a state machine now: the first page fixes the snapshot, every later page continues from the exact cursor it was asked for, rows begin at the next sequence and stay adjacent and within the anchor, an empty snapshot is terminal and carries nothing, and terminal means exactly at the anchor. A record must be one of its two legal shapes, so a stop status without a stop, or a member the shape does not declare, is refused rather than ignored. The page's own byte bound is enforced on both sides, including a single row too large to page at all. The public boundary leaked in both directions. Members that return `Result` raised when the link raised; a body that failed propagated instead of returning the failed `Result` the same condition returns from the local host; and the publication link's own errors reached callers as themselves, so a private refusal class and its spelling crossed a provider-neutral interface. Each is translated once now, at the adapter, and the storage distinctions are kept: a store belonging to something else is not damage, a version this build does not implement is not damage, and a record it cannot read is not an unreachable owner. Those are different facts and a host acts on them differently. Retrieval accepted any well-formed answer. A performed reply naming different metadata would have installed where the definition is fetched from — so the answer is now required to be canonically the value that was asked for, and a contradiction fails closed with the snapshot untouched. Input is parsed by the rules a stored value is held to and bounded before anything is sent, because a value this build will not keep is not a request to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
…read The owner validated each execution row and then sent the physical row anyway, so the composed owner-to-client path could not return a single execution. Both halves passed against a hand-built counterpart, which is exactly how the disagreement survived; the new workerd regression composes the real pages through the real client and fails against the previous commit. The page bound is now one function both ends call, measuring the exact `rows` member as it crosses. The unsupported-version refusal carries the version the owner actually read instead of a placeholder. A retrieval answer describing another replacement is settled where the answer arrives, closing the channel. The message bound is enforced against the complete serialized request, correlation id included, before it is registered or sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
A parser failure was discarded in favour of the channel's, so a contradictory retrieval answer reached the public boundary as an unreachable owner. The request whose answer could not be read now keeps that failure and every other waiter still learns the channel ended, which lets the adapter report a malformed record. A commit answer this build cannot read is undecided for the same reason a lost connection is. An oversized request is classified once, at the adapter boundary, so the database returns a request failure rather than a transaction failure; the duplicate mapping that could never run is gone. Schema versions now have one domain, taken from the carrier they live in and used at marker recognition, refusal encoding, refusal parsing and the public error. Version zero is a partial initialization and so damage, and a value the carrier could never hold is damaged retained data — neither is a version this build is behind. The composed execution regression now writes its later row between the two page requests of one read, and both ends are held to the shared page bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
The Deno coordinator opens a transaction, hands a mutation the authoritative filesystem and the retained metadata, and commits both together. This is that shape with the storage somewhere else: the Workspace is a real directory materialized from the exact admitted root, the metadata is a detached snapshot of that same admitted state, and the commit is one intent the owner performs atomically or not at all. Root, journal anchor and every retained mapping are admitted in one owner-side read, because they are one state. It is complete rather than paged: the mapping tables are insert-only, so a cursor over sorted names cannot be made safe by root and journal equality — a later insert can sort before it and never be seen. A count and byte ceiling refuses instead, and refuses whole. The attempt is created outside the transaction, because the collector seals it after the body tears down. Inside the exact callback the route proves it starts from the admitted state before the document runs, and drift refuses there. A documented Workspace failure journals against the unchanged root; everything else raises and publishes nothing. The Filesystem contract, the Repository/Worktree metadata shapes, the journaled-failure base class and Agent-session reconciliation move to neutral modules so both hosts implement one contract rather than two. The Deno adapter keeps its rows, its savepoint and its synchronous-SQLite exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Lexical admission compared the spelling of a path with the attempt root and then handed it to a syscall that follows symbolic links. An in-tree link read and overwrote a file outside the attempt, and `..notes.md` was refused for beginning with two dots. Every operation now resolves before it acts, on the rule `packages/runtime/host-files.ts` states: only a complete `..` segment leaves, the existing prefix is walked so a path that does not exist yet is still judged, an operation about a link does not follow it, and both ends of a rename or hardlink are admitted at the time of use. A link's target is a Workspace path, so an absolute one names the Workspace root rather than the machine's — resolution is done here rather than by the kernel. The runtime, database, owner link, journal and provenance were supplied separately and could be recombined: pair run B's handle with run A's link and journal, and if both began at the same root and anchor, one effect journalled in A and published its Workspace in B. `useRemoteRun()` now constructs them together and hands back one opaque binding, so there is nothing to recombine and nothing structural to forge. Wiring the production coordinator to a real Durable Object found a third defect the scripted owner could not: a sealed proposal listed its content manifests before its blobs, while the owner requires one sequence ordered by kind and digest. Two owners agreeing about a proposal is what that test is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
`useRemoteRun()` took the database/commit link and the Workspace read link as two unrelated members. A caller could pass B's link and A's reads and get a legitimate, module-created binding: the invocation would be admitted from A's retained mappings and content, and if both owners began at the same root and anchor the start-anchor check passed and the result, the Files proposal and the typed deltas committed to B. There is one member now. `cloudflareRunLink()` builds its own read link from the same connection and returns one value satisfying both halves, so the reads an invocation is admitted from and the commits it publishes are the same authority by construction rather than by a check. The runtime's read view is derived from that object — `frontier` names two different reads on the two contracts, and materialization wants the coherent one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP9dEis51dcwBnwdaXZQAg
Owner
Author
|
Recreated in the upstream repository as taras#763. |
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
This stacked change implements the remote-workflow foundation from taras/executable.md#698 on top of the software-factory contract in taras/executable.md#736. It lets a disposable runner work against workflow and Workspace state durably owned by a Cloudflare Durable Object without moving provider-specific authority into shared workflow code.
What changes
Before:
Executable.md had the software-factory contract and local Deno workflow storage, but no concrete remote owner, private executor transport, coherent remote reads, or atomic remote Workspace publication path.
After:
A Cloudflare-backed owner can admit an executor over a private bounded connection, serve coherent retained snapshots and content, expose a complete internal remote
WorkflowRunDatabase, run Workspace effects on the runner, and atomically retain the resulting root, mappings, journal entries, and execution record. The runner remains disposable and provider-neutral code sees only semantic records and failures.How it works
Cloudflare-specific codecs, authentication, SQLite storage, retry decisions, and Durable Object routing stay in the adapter. Provider-neutral remote modules own materialization, transaction collection, database behavior, mapping reconciliation, and the Workspace coordinator.
Review guide
Start with:
packages/workflow/tests/cloudflare/remote-workspace.vitest.tsThen review:
packages/workflow/src/remote/workspace.tsand the remote Workspace/database tests for the runner-side contractpackages/workflow/src/remote/collector.tsandpackages/workflow/src/remote/database.tsfor transaction lifetime and publication authoritypackages/workflow/src/cloudflare/owner.ts,owner-reads.ts,owner-transaction.ts, andpublish.tsfor durable owner behaviorpackages/workflow/src/cloudflare/acquisition.ts,dispatcher.ts, andclient.tsfor private transport and strict wire validationspecs/workflow-workspace-spec.md,specs/workflow-spec.md, andarchitecture.mdfor the settled contractsLook carefully at:
What must stay true
RemoteWorkspaceLinkas one construction value and checked by the two-owner regression.How to verify it
pnpm test:cloudflareruns the real-workerd acquisition, storage, publication, retry, rollback, hibernation, and remote Workspace integration suite.pnpm check:cloudflaretypechecks the Cloudflare-only runtime boundary.deno task test packages/workflow/tests/remote-client.test.ts packages/workflow/tests/remote-database.test.ts packages/workflow/tests/remote-interoperability.test.ts packages/workflow/tests/remote-materialization.test.ts packages/workflow/tests/remote-publication.test.ts packages/workflow/tests/remote-read.test.ts packages/workflow/tests/remote-transaction.test.ts packages/workflow/tests/remote-workspace-files.test.ts packages/workflow/tests/remote-workspace.test.tschecks the provider-neutral remote contract and native Workspace filesystem containment.deno task check,deno task lint, anddeno task check:jsrpassed at feedback head5b04ad217f79eff5704efabed4ef1b6f1ff724ec.Scope
Included
Intentionally unchanged
WorkflowHost, final public Cloudflare client assembly, CLI selection/configuration, and delivery reconciliationWorkflowHostboundaryNew abstractions
RemoteWorkspaceLinkcarries retained reads and publication through one owner authority so supported construction cannot split a run across owners.New dependencies
@cloudflare/vitest-plugin,@cloudflare/workers-types, and Vitest runner packages provide the real workerd test and typecheck boundary for the Cloudflare adapter.Generated or mechanical changes
pnpm-lock.yamlanddeno.lockrecord the Cloudflare test-runtime dependency graph.check:cloudflareandtest:cloudflareinstead.Risks and limitations
minkimcello/executable.mdbecause PR 🏭 Complete the software-factory contract above PR #630 taras/executable.md#736's head branch exists in that fork. After 🏭 Complete the software-factory contract above PR #630 taras/executable.md#736 lands, this work must be rebased/retargeted into the upstream delivery PR.Scope confirmation