Skip to content

📁 Give a workflow run's document its own filesystem (#366 PR 1) - #426

Draft
taras wants to merge 9 commits into
mainfrom
agent/issue-366-workflow-cli
Draft

📁 Give a workflow run's document its own filesystem (#366 PR 1)#426
taras wants to merge 9 commits into
mainfrom
agent/issue-366-workflow-cli

Conversation

@taras

@taras taras commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Why

xmd workflow needs a document filesystem that belongs to the run rather than to
whoever invoked the CLI. Until now the only Workspace mutation path was
adapter-private: <File> and <Glob> had no way to reach a run's Workspace, so
a workflow document would have written into the caller's directory with nothing
retained and nothing to resume from.

This is PR 1 of a two-PR stack for #366. It builds the provider layer and the
retained-run installation the CLI will compose over. PR 2 (#428) adds
xmd workflow start / resume, the Git definition adapter, cross-process
acceptance tests, and closes #366.

What changes

Before:

  • API.Files had one provider — the host adapter every entrypoint installs.
    The Deno Workspace coordinator existed but only an adapter-private proof
    operation selected it, and <File> could not reach it.
  • useWorkflow({ base }) was the only installation. It allocates a run id and
    resolves the base through Git, which a host that already created the storage
    record cannot use: the id it invents cannot agree with the record.

After:

  • withWorkflowWorkspace(database, operation) installs a run's Workspace effect
    coordinator, the logical working directory /, and a transaction-bound
    API.Files provider — inside the execution, so they answer ahead of the host
    adapter. A document's <File> and <Glob> then name entries in the run's own
    logical filesystem. It is the only installation the entrypoint publishes.
  • useRetainedWorkflow(run) installs a run that already exists. It records the
    exact frozen { runId, base, pinnedCommit }, allocates nothing and never calls
    Git.revParse().
  • Workflow-run identity is execution-owned, not ReplayGuard policy. Both
    installations wrap the journal they were handed and decide identity inside
    readAll, on the same terms core holds a resumed run to its recorded root
    selection.

xmd run, xmd test and the host provider are untouched. No CLI reaches any of
this yet.

The authority correction

Two rounds of review found the same shape of defect twice, and the second is why
this is no longer a wrapper at all.

Round one: identity was enforced in public ReplayGuard.check/admit. A
guard handler installed further out may answer without calling next, so
completing a journal under run-a and replaying it as run-b under a suppressing
guard returned run-a's recorded Close.

Round two: moving the check into a stream wrapper installed by
Execution.around({ execute }, { at: "min" }) was still ordering-dependent.
Execution is composable too — a handler registered at the same position can
rebuild the options a later one produced, stream included — so a handler that
put the raw journal back defeated the wrapper, in one of the two registration
orders.

The fix stops wrapping. An installation now contributes a requirement and core
owns the read:

useWorkflow / useRetainedWorkflow
  → admitJournal(requirement)          ← recorded, not wrapped

executeDocument
  → requiredJournalAdmissions()        ← read after every Execution handler
  → secret filter                      (core)
  → execution-owned admission wrapper  (core)
       retainEvents(...)               ← the history is settled once, here
       requirement(retained)           ← workflow-run identity
       admitRootHistory(retained)      ← recorded root selection
  → durableRun → ReplayGuard check → admit → decide → terminal reuse

By the time any middleware runs, the read has already happened. No Execution
handler can reach the stream the requirement applies to, and no ReplayGuard
handler — including a same-named one from another loaded copy — can skip it. No
new trusted wrapping site is created either: the requirement runs inside core's
existing one, so architecture.md's inventory is back to two wrappers, with the
second described as also applying installed admissions.

One retained snapshot. The requirement is handed the array core retained, not
a second stream.readAll(). retainEvents settles every discriminator once and
is idempotent, so identity admission, ReplayGuard observation, indexing and
terminal reuse all consume the same objects — a backend accessor that answers
differently on a second read cannot be admitted as one run and replayed as
another (RR18).

Two installation policies, not one. A record identifies a run only as the
root coroutine's successfully settled Yield under the canonical type and name,
holding a closed value of exactly runId/base/pinnedCommit, agreeing with
the installed identity. Both installations refuse more than one, and refuse one
that will not read. They differ in whether one must be present:

  • useRetainedWorkflow(run) — required. A host created the run before anything
    executed, so a non-empty history with no successful record is not this run's.
  • useWorkflow({ base }) — not required. §6 records an unresolvable base as a
    failed effect, so the history it wrote is that failure; demanding a
    successful record would refuse a journal this run itself wrote (WR18).

The filesystem seam correction

The previous head reached the Workspace filesystem through a contextual Api. Not
being exported was insufficient: contextual APIs compose by stable name across
loaded copies, so a component could rebuild the descriptor and interpose on a
later file effect. It is now provider-owned dependency injection — an internal
option to installWorkflowRunStorage(), captured in the provider's closure,
never handed to a scope, a context or a descendant. useWorkflowRunStorage() (the
published entrypoint) cannot express it.

How it works

<File path="notes.md">…</File>
  → API.Files (workflow provider, at: "min")
  → lexical admission on POSIX segments rooted at "/"
  → durable Workspace effect (expansion + operation + resolved path)
  → run's effect transaction: mutation savepoint → immutable root → filtered Yield → COMMIT
  → Ok(fileWriteSuccess("transaction-staged"))

Replay never enters that path: the recorded outcome is parsed out of the journal
and handed back, so no mutation runs, no transaction opens and no current file is
consulted.

Review guide

Start with: packages/workflow/src/deno/workspace/files.ts

Then review:

  1. specs/workflow-spec.md §3.1, §3.2 and §10 — the three contracts this adds.
  2. packages/workflow/src/run.ts — the requirement each installation
    contributes through admitJournal(), and why it is contributed rather than
    wrapped.
  3. packages/workflow/src/journal.ts — canonical-record recognition, and the
    refusals that name differing fields without their values.
  4. packages/workflow/src/deno/workspace/logical-path.ts — why containment here
    needs no stable-namespace qualification.
  5. packages/workflow/src/deno/workspace/host.ts — what a host installs, why the
    three pieces are not published separately, and that a completed replay is
    deliberately not this path.

Look carefully at:

  • The outcome envelope in files.ts. A refusal is retained as a phase and a
    reason, never as a serialized error, so no DOFS message, errno payload, SQLite
    text or resolved path is ever written to the journal — and a restored refusal
    is rebuilt from the same vocabulary a live one is.
  • parseOutcome. The journal is untrusted protocol data, so parsing is total: a
    record must carry its variant's members and no others, each of the declared
    type, and a refusal's phase and reason must both be words the operation's
    vocabulary holds. Everything else is the one fixed cause-free
    FilesInvariantError("protocol"), carrying nothing the record happened to hold.
  • writeOutcome: the mutation savepoint wraps parent creation and the write,
    so a refusal discards partial logical mutation before the sanitized result is
    published. That is what makes target: "rolled-back" true rather than
    aspirational — and it is now observed rather than argued (WF12).
  • asRefusal rethrows anything DOFS did not document. Turning an infrastructure
    condition into a printable reason would let the work after a file effect run as
    though the file effect had happened.
  • descend: a search answers with regular files, on HF3's contract. A symbolic
    link is neither a result nor a way into the tree it names.

What must stay true

  • A workflow document never reaches the caller's filesystem — enforced by
    installing the provider at { at: "min" } inside the execution, and checked by
    a host API.Files observer installed outside the run in every WF test. It
    stays empty for reads, writes, refusals and searches.
  • <Glob> answers the same way under either provider — regular files only,
    no link reported and no link followed (WF11 mirrors HF3).
  • Replay consults no current state — enforced by making every operation a
    durable effect and reading the recorded value back; checked by WF4 and WF5.
  • Retained history is parsed, not believed — for the run record (RR8/RR9/RR14)
    and for every file outcome (WF13).
  • Suppressing, replacing or reordering guard policy cannot bypass workflow-run
    identity
    — RR10 (all three phases), RR11 (a same-named guard from another
    copy), with RR12/RR13 showing valid replay and valid policy still compose.
  • The journal is the run's own — enforced by the existing provenance check in
    the coordinator; checked by WF9.
  • The three installations are one authority boundarywithWorkflowWorkspace()
    is the only one exported from packages/workflow/deno.ts.
  • The transaction filesystem is the provider's — WF14.

How to verify it

Each mutation below was applied to the working tree, the named suite re-run, and
the source restored from HEAD and confirmed byte-identical (git status clean)
before the next one.

# Mutation Tests killed
P1 the requirement goes back into a stream wrapper the installation owns RR17, RR18
P2 the requirement is handed a second, unretained readAll() RR18
P3 a programmatic run demands a successful record WR18
P4 a retained run stops demanding one RR8, RR14, RR15
Q1b runClaim rethrows a refused read instead of converting it RR19, RR20
Q2 failed canonical claims are left out of the duplicate count RR21
Q3 a mixed successful/failed history is accepted RR21
Q4 the host-supplied retained run is read without totality RR22
N1 the installation stops contributing its requirement RR8, RR9, RR10, RR11, RR14, RR15, RR16, WR4, WR5, WR6, WR13
N2 identity moved back into a public ReplayGuard.check (policy-only) RR8, RR10, RR14, RR15
N3 canonical recognition weakened to effect type only RR14
N4 the owning coroutine is not checked RR14
N5 admission accepts more than one successful record RR8
N6 the recorded run value may carry extra members RR14
N7 refusals retain the recording description (with its base) RR16
N8 the filesystem decorator becomes a contextual Api again WF14
N9 content is coerced instead of type-checked WF13
N10 glob path entries are coerced instead of type-checked WF13
M1 descend drops the entry.kind !== "file" skip WF11
M2 descend walks through symlinks as well as directories WF11
M3 parseOutcome drops the exact-members check WF13 (both tests)
M4 parseOutcome falls back instead of refusing an unknown phase or reason WF13
M5 writeOutcome calls replace(...) without the nested savepoint WF12
M8 resolveLogicalPath stops refusing an empty path WF6
M9 host.ts does not install useWorkflowFiles, leaving the host provider in place 16 of the 17 WF steps, starting with the host-observer assertion in WF1
M10 effect.ts skips connections.validateJournalProvenance(...) WF9 only

P1 and N2 are the ones that matter most: each restores exactly the design a
review round rejected. N2 puts identity back in ReplayGuard and RR10 dies; P1
puts it back in an Execution-installed wrapper and RR17 dies.

Seven honest limits on that table:

  • Blocker 3 is delivered only in part, and the rest is not mine to decide.
    The review's expected behaviour was "replay reproduces the recorded Git
    failure". It does not, and it did not before this PR: that journal holds a root
    Close and no root import, and core's target admission refuses any terminal
    history in that shape. I reproduced it on main at b324b97 — the replay
    reports "The recorded root document import cannot be read by this version.",
    not the Git failure. So workflow-spec §6 is already untrue on main. What this
    PR delivers is that the workflow installation adds no refusal of its own
    there (P3 discriminates it); making §6 true means relaxing core's rule about a
    Close without the import that authorized it, which is a core authority change
    outside PR 1's scope. §6 now records the contradiction instead of asserting the
    behaviour.

  • The hostile-value mutation on readWorkflowRun fails nothing, and the table
    says so.
    I ran it: letting Object.entries throw its own exception changes
    no test. The reason is that retainEvents rebuilds a recorded value before
    anything reads it, so a hostile proxy raises during retention and reaches this
    package as a retained event whose result getter re-raises. runClaim's catch
    is what converts it — Q1b kills RR19 and RR20 — and readWorkflowRun's own
    totality is defence in depth on the journal path. Where it is load-bearing is
    the host-supplied side (useRetainedWorkflow), which Q4 discriminates.

  • A mutation that replaces the fixed FilesInvariantError("protocol") with a
    generic caused error does not fail anything, and I am not claiming otherwise.

    I ran it. packages/core/src/files.ts:67 (invokeFiles) already converts any
    non-Files throw out of a provider into a fresh cause-free protocol invariant,
    so the provider's own throw is defence in depth and the observable guarantee is
    core's. WF13 asserts the exact invariant via parseFilesFatal() and that it
    carries no cause; what it cannot do is attribute that guarantee to this
    provider.

  • WF14's discriminating adversary is the enclosing scope, not the component.
    My first version installed the impostor seams from inside a component and
    passed even under N8 — a component's own installations do not enclose the
    effect the next element performs, so the test was vacuous. It now installs from
    a scope that wraps the whole document and from a component, and N8 kills it.
    The component half is coverage, not proof.

  • The export narrowing is a surface change, not a behavior one.
    useWorkflowFiles and useLogicalWorkspaceCwd are gone from
    packages/workflow/deno.ts; useLogicalWorkspaceCwd is module-private. No
    runtime mutation discriminates it, so the evidence is deno task check:jsr and
    a repository-wide grep showing no consumer outside src/deno/workspace/.

  • WF3–WF5's mutation evidence is inherited from 20b7034, where "reads the
    current frontier instead of the recorded result" killed them. The replay
    machinery it targets is unchanged by this revision, and no mutation re-run here
    covers it.

  • WF11's non-traversal half is asserted, and half-discriminated. M2 shows the
    assertion fails when directory symlinks are descended. DOFS readdir types a
    link as symlink regardless of what it points at, so non-traversal was already
    true before this change — what M1 fixes is the link being reported.

Other scenarios:

  • WF2 counts committed journal rows and reads workspace_state through a
    second connection, so it reports what the transaction published rather than
    what this handle holds, and compares the current-root pointer with the root the
    newest journal row names.
  • WF7 pre-creates /blocked as a file and writes blocked/deep/x.txt. It proves
    the refusal is published as rolled-back, the current root is byte-identical to
    the one before the effect, and /blocked/deep does not exist.
  • WF12 is the rollback proof. DOFS stops nowhere between mkdir -p and the write
    — a parent chain that can be created is a chain the file can then be written
    into — so the failure is planted through the provider's own installation
    option: installWorkflowRunStorage(options, { decorateFilesystem }), captured
    in the provider closure and expressible from no entrypoint and no scope. The
    planted failure is raised through the adapter's own wrapping, so what the
    provider sees is indistinguishable from a real EACCES. WF12 then asserts both
    created parents are gone, /kept.txt is untouched, the sanitized refusal is
    what the transaction recorded, and the document's next write still commits.
  • WF13 plants outcomes directly into journal_events through SQL and drops the
    root Close so the effects actually replay. It covers extra members on
    content, written and paths, a refused missing its reason, a refused
    in a vocabulary this provider does not speak, and a refused carrying planted
    text — asserting that text never appears in what comes back. Its second test
    truncates the journal after the first write so the second one would run live,
    and shows the malformed record stops it: no file effect is appended and
    /second.txt is not recreated.
  • RR8 builds three completed journals from a real run — one with the
    workflow_run Yield removed, one with it recorded as failed, one with it
    duplicated — and asserts each is refused as StaleInputError before the root
    result is returned, with nothing expanded and nothing appended.
  • RR4/RR5 assert the refusal message contains the differing field names and
    contains neither run id, so a caller-selected id cannot leak into a log.
  • RR1 installs a Git provider that throws on any call, so "resolves no base" is
    asserted rather than assumed.

Commands run on 7f6e8565b40d8686562dd89583e3490df27a9c48 (rebased onto main at b324b97):

deno task lint                         # clean (pre-existing warnings only)
deno task check                        # clean
deno task check:jsr                    # Success Dry run complete
deno task test --changed=origin/main   # 301 passed (2172 steps), 0 failed
git diff --check                       # clean

Focused suites: Tier RR (22 steps), Tier WR (18 steps), Tier WF (18 steps), and the
WAC/DLC Workspace transaction, crash-recovery and loaded-copy suites (27 steps).

Scope

Included

  • Transaction-bound API.Files provider over the run's logical DOFS Workspace.
  • withWorkflowWorkspace() — coordinator, logical cwd /, provider — as the
    single public installation boundary.
  • useRetainedWorkflow() — exact retained-run installation.
  • Execution-owned workflow-run identity: a trusted journal wrapper that decides it
    inside readAll, ahead of every public ReplayGuard phase and of terminal
    reuse, and carries journal provenance without establishing it.
  • Provider-owned injection of the Workspace transaction filesystem, replacing the
    contextual seam.
  • parseFilesReason / parseFilesPhase / parseFileWritePhase exported from
    @executablemd/runtime so a provider reading a retained refusal back out of
    storage parses the one vocabulary rather than declaring a second copy.
  • Specification and architecture updates for everything above.

Intentionally unchanged

  • No CLI. xmd workflow does not exist yet, and no specification in this PR says
    it does.
  • xmd run and xmd test keep useHostFiles() exactly as they had it.
  • useWorkflow({ base }) establishment behavior is unchanged: it is still held
    to the recorded base and nothing more, a live empty history still starts, and
    WR1–WR17 pass untouched. What moved is where that comparison happens.
  • Repository, Worktree, Git effects, Agent materialization and history forks
    remain unbuilt.
  • Completed-replay attachment behavior is specified here (the host path is the
    attaching one) but the decision of when not to attach belongs to PR 2's CLI,
    where it is testable end to end.

Generated or mechanical changes

  • createWorkspaceProofEffectcreateWorkspaceEffect across
    packages/workflow. Pure rename: the operation is no longer adapter-private
    proof-only, so the name was misleading. Three test/support files import it;
    none of their assertions changed.

Risks and limitations

  • Every non-mutating effect still captures a root. It is content-addressed, so
    the result is correct and the pointer set is a no-op, but a read-heavy document
    pays for a frontier traversal per read. Left as is deliberately — an
    optimization here would have to prove that no DOFS read touches metadata the
    root identity covers.
  • Concurrent-executor safety is not claimed. Add workflow inspection, cancellation, suspension, and deletion #367 owns it.
  • The admission ownership model is not finalized here. This body describes
    what the code does; whether contributing a requirement to core's read is the
    right home for workflow-run identity is the architect's call, and the files
    below are deliberately left in that state pending it:
    packages/core/src/journal-admission.ts, packages/core/src/execute.ts
    (the required loop in guardedJournal), packages/workflow/src/run.ts
    (admits() / install()), architecture.md's wrapping-site paragraph, and
    specs/workflow-spec.md §3.2.
  • The Workspace filesystem decorator that WF12 needs is an internal option to
    installWorkflowRunStorage(), held in the provider's closure. It is reachable
    from no entrypoint, no context and no scope, but it is still a place where the
    filesystem a transaction hands its body can be replaced, and
    useWorkflowRunStorage() must stay unable to express it.
  • admitJournal() is new public surface on @executablemd/core. It is a
    contribution point, not an authority handout: an admission can only add a
    refusal, never remove one, and core reads what is installed rather than letting
    a caller supply it per execution.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

@taras
taras force-pushed the agent/issue-366-workflow-cli branch from 9010005 to 20b7034 Compare August 10, 2026 14:31
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

PR #426: 📁 Give a workflow run's document its own filesystem (#366 PR 1)

28 files, +3712 / -131

Scope

🔴 PR has 3843 lines changed. Split into focused PRs.

🟡 3843 lines changed. PRs under 400 receive more thorough review.

🟡 28 files changed. Are all changes related?

🟡 Changes span 8 directories.

Structural

Oxlint structural signals:

  • no-empty-function ×3: packages/core/src/execute.ts, packages/workflow/src/deno/provider.ts
  • no-unnecessary-type-assertion ×3: packages/core/src/execute.ts
  • no-unused-vars ×1: packages/workflow/src/deno/provider.ts

Slop

✅ Slop indicators look low.

Static Analysis

Oxlint: 25 diagnostics across 8 files (10 rules)
Density: 0.007 violations/added-line

no-unsafe-type-assertion (4): packages/core/src/execute.ts
no-shadow (3): packages/workflow/src/deno/workspace/private.ts, packages/core/src/execute.ts
no-empty-function (3): packages/core/src/execute.ts, packages/workflow/src/deno/provider.ts
no-floating-promises (3): packages/workflow/tests/support/workspace-restart-child.ts, packages/workflow/tests/support/workspace-crash-child.ts, packages/core/src/execute.ts
unbound-method (3): packages/workflow/src/deno/workspace/effect.ts, packages/core/src/execute.ts
no-unnecessary-type-assertion (3): packages/core/src/execute.ts
no-array-sort (2): packages/runtime/files.ts, packages/workflow/src/deno/workspace/files.ts
consistent-return (2): packages/core/src/execute.ts
no-useless-spread (1): packages/runtime/files.ts
no-unused-vars (1): packages/workflow/src/deno/provider.ts

Correctness

No extraneous code patterns detected.

@taras

taras commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Stacked: #428 is PR 2 of this pair — the xmd workflow start / resume CLI lifecycle, the Git definition adapter, cross-process acceptance tests and documentation. It is based on this branch rather than main, and it closes #366. Review this one first.

taras added 6 commits August 10, 2026 13:25
A workflow run's `<File>` and `<Glob>` now reach the run's logical Workspace
instead of the caller's filesystem. Each read, write and search is one durable
Workspace effect, so the mutation, the immutable root it produces and the
filtered journal result commit together, and a replay restores the recorded
outcome without performing the mutation or asking what the file is now.

An authored path is resolved by arithmetic on POSIX segments rooted at `/`, so
no host path exists for a namespace race to replace. A documented DOFS refusal
rolls its mutation savepoint back before the sanitized result is published and
crosses the boundary as a `FilesReason` and nothing else; everything that is
not a documented refusal stays an infrastructure failure. A temporary directory
is refused rather than emulated.

`useRetainedWorkflow(run)` is the other half: a host that has already created
the run's storage record installs the exact frozen value, so the execution
allocates no identifier and resolves no base, and every journal state requires
the record to agree in run id, base and pinned commit.

The CLI cannot reach any of this yet; `xmd run` and `xmd test` keep the host
provider untouched.
A search answers with regular files, on the contract the host provider
already answers on: a symbolic link is neither a result nor a way into
the tree it names.

A recorded outcome is parsed rather than believed. A record must carry
its variant's members and no others, and a refusal's phase and reason
must both be words the operation's vocabulary holds; anything else is
the one fixed cause-free provider invariant, carrying nothing the record
happened to hold.

A retained installation requires the retained history as a whole to hold
exactly one successful workflow_run record that reads as a run and
agrees with the retained one. Reading a record can only refuse a record
the journal holds, so a completed journal recording a terminal result
and no run at all had nothing to refuse.

The document filesystem is installed through withWorkflowWorkspace() and
nowhere else. The Files provider alone would resolve a document's paths
against the surrounding host's working directory, and retain it.

DOFS stops nowhere between creating a write's parents and writing the
file, so the savepoint's rollback is observed through an adapter-private
interposition on the filesystem a Workspace transaction hands its body.
A ReplayGuard is composable policy: a handler installed further out may
answer without delegating. Identity decided there depended on middleware
ordering, and a completed journal reached under a suppressed guard
handed its recorded root result to whichever run asked.

The comparison is now a step inside the journal's own readAll, on the
terms core holds a resumed run to its recorded root selection: reachable
through no context, replaceable by nothing, ahead of every guard phase,
of terminal reuse, of live execution and of any append. It carries the
witness its source stream already had and establishes none.

A record identifies a run only as the root coroutine's successfully
settled Yield under the canonical type and the canonical name, holding a
closed value of exactly the three members a run has. Any history with
events must carry exactly one, so a same-typed Yield written elsewhere
cannot stand in for the record that was removed.

The filesystem a Workspace transaction hands its body is injected where
the provider is installed and kept in its closure. A stable Api name is
composition, and a component that reconstructs one reached the
authoritative filesystem through the seam this replaces.

Refusals retain a description holding the effect's type and name alone,
so nothing about the run stays reachable on the error object.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the xmd workflow start/resume filesystem vertical slice

1 participant