📁 Give a workflow run's document its own filesystem (#366 PR 1) - #426
📁 Give a workflow run's document its own filesystem (#366 PR 1)#426taras wants to merge 9 commits into
Conversation
9010005 to
20b7034
Compare
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. StructuralOxlint structural signals:
Slop✅ Slop indicators look low. Static AnalysisOxlint: 25 diagnostics across 8 files (10 rules) no-unsafe-type-assertion (4): packages/core/src/execute.ts CorrectnessNo extraneous code patterns detected. |
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.
bdcb258 to
2adfc45
Compare
Why
xmd workflowneeds a document filesystem that belongs to the run rather than towhoever 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, soa 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-processacceptance tests, and closes #366.
What changes
Before:
API.Fileshad 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 andresolves 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 effectcoordinator, the logical working directory
/, and a transaction-boundAPI.Filesprovider — inside the execution, so they answer ahead of the hostadapter. A document's
<File>and<Glob>then name entries in the run's ownlogical filesystem. It is the only installation the entrypoint publishes.
useRetainedWorkflow(run)installs a run that already exists. It records theexact frozen
{ runId, base, pinnedCommit }, allocates nothing and never callsGit.revParse().ReplayGuardpolicy. Bothinstallations wrap the journal they were handed and decide identity inside
readAll, on the same terms core holds a resumed run to its recorded rootselection.
xmd run,xmd testand the host provider are untouched. No CLI reaches any ofthis 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. Aguard handler installed further out may answer without calling
next, socompleting 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.Executionis composable too — a handler registered at the same position canrebuild 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:
By the time any middleware runs, the read has already happened. No
Executionhandler can reach the stream the requirement applies to, and no
ReplayGuardhandler — 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 thesecond described as also applying installed admissions.
One retained snapshot. The requirement is handed the array core retained, not
a second
stream.readAll().retainEventssettles every discriminator once andis idempotent, so identity admission,
ReplayGuardobservation, indexing andterminal 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 withthe 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 anythingexecuted, so a non-empty history with no successful record is not this run's.
useWorkflow({ base })— not required. §6 records an unresolvable base as afailed 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()(thepublished entrypoint) cannot express it.
How it works
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.tsThen review:
specs/workflow-spec.md§3.1, §3.2 and §10 — the three contracts this adds.packages/workflow/src/run.ts— the requirement each installationcontributes through
admitJournal(), and why it is contributed rather thanwrapped.
packages/workflow/src/journal.ts— canonical-record recognition, and therefusals that name differing fields without their values.
packages/workflow/src/deno/workspace/logical-path.ts— why containment hereneeds no stable-namespace qualification.
packages/workflow/src/deno/workspace/host.ts— what a host installs, why thethree pieces are not published separately, and that a completed replay is
deliberately not this path.
Look carefully at:
files.ts. A refusal is retained as a phase and areason, 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: arecord 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 thanaspirational — and it is now observed rather than argued (WF12).
asRefusalrethrows anything DOFS did not document. Turning an infrastructurecondition 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 symboliclink is neither a result nor a way into the tree it names.
What must stay true
installing the provider at
{ at: "min" }inside the execution, and checked bya host
API.Filesobserver installed outside the run in every WF test. Itstays 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).
durable effect and reading the recorded value back; checked by WF4 and WF5.
and for every file outcome (WF13).
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 coordinator; checked by WF9.
withWorkflowWorkspace()is the only one exported from
packages/workflow/deno.ts.How to verify it
Each mutation below was applied to the working tree, the named suite re-run, and
the source restored from
HEADand confirmed byte-identical (git statusclean)before the next one.
readAll()runClaimrethrows a refused read instead of converting itReplayGuard.check(policy-only)base)contentis coerced instead of type-checkeddescenddrops theentry.kind !== "file"skipdescendwalks through symlinks as well as directoriesparseOutcomedrops the exact-members checkparseOutcomefalls back instead of refusing an unknown phase or reasonwriteOutcomecallsreplace(...)without the nested savepointresolveLogicalPathstops refusing an empty pathhost.tsdoes not installuseWorkflowFiles, leaving the host provider in placeeffect.tsskipsconnections.validateJournalProvenance(...)P1 and N2 are the ones that matter most: each restores exactly the design a
review round rejected. N2 puts identity back in
ReplayGuardand RR10 dies; P1puts 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
Closeand no root import, and core's target admission refuses any terminalhistory in that shape. I reproduced it on
mainat b324b97 — the replayreports "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 thisPR 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
Closewithout the import that authorized it, which is a core authority changeoutside PR 1's scope. §6 now records the contradiction instead of asserting the
behaviour.
The hostile-value mutation on
readWorkflowRunfails nothing, and the tablesays so. I ran it: letting
Object.entriesthrow its own exception changesno test. The reason is that
retainEventsrebuilds a recorded value beforeanything reads it, so a hostile proxy raises during retention and reaches this
package as a retained event whose
resultgetter re-raises.runClaim's catchis what converts it — Q1b kills RR19 and RR20 — and
readWorkflowRun's owntotality 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 ageneric caused error does not fail anything, and I am not claiming otherwise.
I ran it.
packages/core/src/files.ts:67(invokeFiles) already converts anynon-Files throw out of a provider into a fresh cause-free
protocolinvariant,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 itcarries no
cause; what it cannot do is attribute that guarantee to thisprovider.
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.
useWorkflowFilesanduseLogicalWorkspaceCwdare gone frompackages/workflow/deno.ts;useLogicalWorkspaceCwdis module-private. Noruntime mutation discriminates it, so the evidence is
deno task check:jsranda repository-wide grep showing no consumer outside
src/deno/workspace/.WF3–WF5's mutation evidence is inherited from
20b7034, where "reads thecurrent 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
readdirtypes alink as
symlinkregardless of what it points at, so non-traversal was alreadytrue before this change — what M1 fixes is the link being reported.
Other scenarios:
workspace_statethrough asecond 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.
/blockedas a file and writesblocked/deep/x.txt. It provesthe refusal is published as
rolled-back, the current root is byte-identical tothe one before the effect, and
/blocked/deepdoes not exist.mkdir -pand 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 }), capturedin 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 bothcreated parents are gone,
/kept.txtis untouched, the sanitized refusal iswhat the transaction recorded, and the document's next write still commits.
journal_eventsthrough SQL and drops theroot
Closeso the effects actually replay. It covers extra members oncontent,writtenandpaths, arefusedmissing its reason, arefusedin a vocabulary this provider does not speak, and a
refusedcarrying plantedtext — 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.txtis not recreated.workflow_runYield removed, one with it recorded as failed, one with itduplicated — and asserts each is refused as
StaleInputErrorbefore the rootresult is returned, with nothing expanded and nothing appended.
contains neither run id, so a caller-selected id cannot leak into a log.
Gitprovider that throws on any call, so "resolves no base" isasserted rather than assumed.
Commands run on
7f6e8565b40d8686562dd89583e3490df27a9c48(rebased ontomainat b324b97):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
API.Filesprovider over the run's logical DOFS Workspace.withWorkflowWorkspace()— coordinator, logical cwd/, provider — as thesingle public installation boundary.
useRetainedWorkflow()— exact retained-run installation.inside
readAll, ahead of every publicReplayGuardphase and of terminalreuse, and carries journal provenance without establishing it.
contextual seam.
parseFilesReason/parseFilesPhase/parseFileWritePhaseexported from@executablemd/runtimeso a provider reading a retained refusal back out ofstorage parses the one vocabulary rather than declaring a second copy.
Intentionally unchanged
xmd workflowdoes not exist yet, and no specification in this PR saysit does.
xmd runandxmd testkeepuseHostFiles()exactly as they had it.useWorkflow({ base })establishment behavior is unchanged: it is still heldto the recorded base and nothing more, a live empty history still starts, and
WR1–WR17 pass untouched. What moved is where that comparison happens.
remain unbuilt.
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
createWorkspaceProofEffect→createWorkspaceEffectacrosspackages/workflow. Pure rename: the operation is no longer adapter-privateproof-only, so the name was misleading. Three test/support files import it;
none of their assertions changed.
Risks and limitations
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.
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
requiredloop inguardedJournal),packages/workflow/src/run.ts(
admits()/install()),architecture.md's wrapping-site paragraph, andspecs/workflow-spec.md§3.2.installWorkflowRunStorage(), held in the provider's closure. It is reachablefrom 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 acontribution 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