💥 Add expansion identity and pinned-commit workflow runs - #341
Conversation
|
|
||
| // This element's own place in the structural path, and the snapshot its body | ||
| // reads (§5.6). Derived before the body runs and unchanged by anything it | ||
| // does, so re-expanding the same element arrives at the same identifier. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // does, so re-expanding the same element arrives at the same identifier. |
|
|
||
| // This element's own place in the structural path, and the snapshot its body | ||
| // reads (§5.6). Derived before the component runs and unchanged by anything it | ||
| // does, so re-expanding the same element arrives at the same identifier. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // does, so re-expanding the same element arrives at the same identifier. |
| const restored = yield* CurrentWorkflowRun.get(); | ||
| // A truncated replay restored the value in the check phase; the durable | ||
| // operation above still had to run so the cursor advanced past its own entry. | ||
| // Keeping that object is what makes every read in one execution the same one. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // Keeping that object is what makes every read in one execution the same one. |
PR #341: 💥 Add expansion identity and pinned-commit workflow runs36 files, +2854 / -214 Scope🔴 PR has 3068 lines changed. Split into focused PRs. 🟡 3068 lines changed. PRs under 400 receive more thorough review. 🟡 36 files changed. Are all changes related? 🟡 PR mixes config and source changes. Structural🟡 Type declarations with no consumers: ExpandSegments.
Slop
Static Analysis✅ Oxlint found no issues. CorrectnessNo extraneous code patterns detected. |
… frame ## Motivation Review of #341 reproduced a real collision: two positionless `<If>` elements, each holding a positionless `<Probe />` at child index 0, produced one identifier. `<If>`, `<Capture>`, `<PrintErrors>` and `<Answers>` recursed on the unchanged parent path, and only ordinary components contributed a frame — so the `@index` fallback separated siblings in one list but not children at the same local index under different parents. ## Approach The element frame is now derived once, in the dispatch arm, for every element — structural construct and component alike — and every recursion boundary carries it. `<Loop>` and `<Each>` iteration frames extend that element path rather than re-encoding the site, and a claimed `<Content />` anchors on its own element path, which already descends from the invocation whose body holds it. The root document's frame is removed. A mutation showed it inert: with `rootPath = ""` every test still passed, because every element in a root's body carries that document's path in its own source position. Root identity therefore enters through positions, which XP19 pins, and an untestable frame is not kept for reassurance. ## Verification XP18 covers the collision and is red without the structural-parent frame. XP19, XP20 and XP21 drive `execute()` — two root documents differ, one root reproduces its identifiers, and a truncated replay derives the ones already recorded — the boundaries the `expandSegments`-driven cases could not reach. WR15 pins that expansion identifiers do not move with the workflow run, WR16 that cancelling an execution does not erase a recorded run, and WR17 that a descriptor of the same name built elsewhere reads the run. The specification claimed "nothing intercepts what durable identities are derived from" and that a same-named context is "refused rather than answered". Both were false once the instance-local brand came out. `getExpansion()` and `getWorkflowRun()` are now described as portable contextual observations that a descendant may rebind — composition boundaries, not authority boundaries — and the Tier XP rows say what the tests actually prove.
## Motivation Durable workflow effects (#291) and workflow-owned worktrees (#293) need a deterministic identity for one logical evaluation of an authored element. Nothing in the engine could supply one: the only run-scoped identifier was the `BlockCounter`, which advances with however much has already run and so moves under retry and under a branch that did or did not execute. `invocation()` already answered the other half of the question — the authored name and where it was written — but only for function components, and only as a Component Api operation any middleware could intercept, while four families of durable record name are derived from it. ## Approach `getExpansion()` replaces `invocation()` with no alias, and covers Markdown component expansions as well. It answers with `{ id, name, position? }`. `id` is derived from the root document and the structural path that reached the element. Each step contributes a frame — root document, authored element, loop iteration, each item, projection — and the path is threaded explicitly as the digest so far, so extending it costs one hash. An element is placed by its own source path and offset rather than by an index into the list being expanded, which is what makes the derivation independent of how much ran before it. `Expansion` is delivered as a plain context value. An Effection context is identified by its name, and two descriptors built with the same name address the same context — the portability mechanism that lets a second instance of core read what the engine published, which is what a repository `.ts` component importing core from disk is while the compiled binary carries its own copy. A first attempt branded the value with a private class field; every Deno check stayed green while the compiled binary failed 2 of 10 documents. Every produced location string is unchanged, so `elicit:`, `prompt:`, `test:`, `testing:` and the web-form record names are byte-identical and existing journals still replay. ## Verification Tier XP (17 tests) discriminates each claim by the derivation it would kill, including two concurrent projections of one slot keeping their identifiers when completion order reverses, a projection operation constructed but never interpreted consuming no ordinal, and a context descriptor built elsewhere reading the engine's expansion — which moves the compiled-binary-only break into the fast suite. `deno task lint`, `check`, `test` (352), `check:jsr`, `tsc --project tsconfig.node.json`, `bun run test:bun` (2342), `deno task build` with the compiled-binary smoke over `packages/core/src`, and `git diff --check` are all green.
## Motivation
A workflow run needs one starting repository state chosen once, so a branch
that moves afterwards cannot change what a recorded run started from, and so
durable records and effects have an explicit identity to hang on.
## Approach
`useWorkflow({ base })` installs middleware and nothing else — installing it
creates no run; executing a document under it does. The run is installed in the
scope that owns the document execution, so it is unreadable before `execute()`,
readable by every descendant of the expansion and by output emitted after the
durable run, and gone once that execution ends.
Two middlewares, because a journal has three states. Live: `Execution.document`
allocates the run id, resolves `${base}^{commit}` through `Git.revParse()`, and
records one immutable value before the root is imported. Truncated: a
`ReplayGuard` check restores the value and the durable operation still runs so
the cursor advances past its own entry. Completed: `durableRun` returns the
recorded root result without invoking the workflow at all, so the check phase —
which runs before that shortcut — is the only place a completed journal can
restore its run, or refuse a base that disagrees with the recorded one before
the result reaches the caller.
Divergence detection compares only an entry's type and name, so the supplied
base is checked against the stored value rather than against the entry's
identity. The journal is parsed, never trusted: a malformed record is described,
never quoted.
`Git.revParse()` is a contextual capability whose default runs
`git rev-parse --verify --end-of-options <revision>` as an argument array in the
contextual working directory. Core never imports workflow or Git.
## Verification
Tiers WR (14) and GT (4). Three mutations show the tests discriminate:
removing the ReplayGuard fails only the three completed-journal tests; dropping
the base comparison fails only the refusal test; and replacing the live run-id
allocation with a throw reddens live initialization while the two hand-seeded
replay tests stay green — the evidence that replay allocates no identifier and
invokes no Git, with no test seam in production code.
`deno task lint`, `check`, `test` (363), `check:jsr`, `tsc --project
tsconfig.node.json`, `pnpm test:node` (2402 of 2403 — the one failure is
`config-api.test.ts`, which reproduces identically on origin/main under local
Node 26), `bun run test:bun` (2402), `deno task build` with the compiled-binary
smoke, and `git diff --check` are green.
The markdown and function component paths computed the same path and snapshot from the same four values, under the same three-line comment. `enterElement()` carries the derivation and the reason once, so the two call sites say only which element they are.
… frame ## Motivation Review of #341 reproduced a real collision: two positionless `<If>` elements, each holding a positionless `<Probe />` at child index 0, produced one identifier. `<If>`, `<Capture>`, `<PrintErrors>` and `<Answers>` recursed on the unchanged parent path, and only ordinary components contributed a frame — so the `@index` fallback separated siblings in one list but not children at the same local index under different parents. ## Approach The element frame is now derived once, in the dispatch arm, for every element — structural construct and component alike — and every recursion boundary carries it. `<Loop>` and `<Each>` iteration frames extend that element path rather than re-encoding the site, and a claimed `<Content />` anchors on its own element path, which already descends from the invocation whose body holds it. The root document's frame is removed. A mutation showed it inert: with `rootPath = ""` every test still passed, because every element in a root's body carries that document's path in its own source position. Root identity therefore enters through positions, which XP19 pins, and an untestable frame is not kept for reassurance. ## Verification XP18 covers the collision and is red without the structural-parent frame. XP19, XP20 and XP21 drive `execute()` — two root documents differ, one root reproduces its identifiers, and a truncated replay derives the ones already recorded — the boundaries the `expandSegments`-driven cases could not reach. WR15 pins that expansion identifiers do not move with the workflow run, WR16 that cancelling an execution does not erase a recorded run, and WR17 that a descriptor of the same name built elsewhere reads the run. The specification claimed "nothing intercepts what durable identities are derived from" and that a same-named context is "refused rather than answered". Both were false once the instance-local brand came out. `getExpansion()` and `getWorkflowRun()` are now described as portable contextual observations that a descendant may rebind — composition boundaries, not authority boundaries — and the Tier XP rows say what the tests actually prove.
`<Else>` is consumed by `ifStructure()` and never reaches expansion's dispatch, so it contributed no frame: both arms of one `<If>` expanded under the same path, and a positionless probe in each arm received one identifier. `ifStructure()` now carries the element and where it sat, and the false arm expands beneath its frame. XP22 covers it and asserts each arm reproduces its own identifier. This is the first of the consumed-parent category review found; `<Answer>`, `<Output>` and body chunking are not yet repaired.
`<Output>` is consumed by `buildBody()` and `<Answer>` by `<Answers>`, so neither reached expansion's dispatch and neither contributed a frame. Body chunking compounded it: a documentation chunk is one segment, so every positionless element in one arrived as index 0. `buildBody()` now carries each chunk's path and where it started in the body, `expandSegments()` takes that index base, and `<Answer>` template children expand beneath their own element's frame while the region's body expands beneath its own. `expandValueBody()` chunks the same way and passes the same base. Per review, no index base is threaded through `execute.ts`: a root body always comes from `parseMarkdownDefinition()`, which scans with a path origin, so no supported path supplies positionless root segments. WR16 no longer races a timer. The component signals through `withResolvers()` once it has observed the recorded run, and the test cancels only after that. Specification and comments drop "composition boundary" and "authority boundary", which #340 owns and main does not yet register; they state the same fact in plain language until #341 rebases onto #340.
XP24 and XP25 cover the two consumed boundaries a hand-built fixture can reach: positionless elements in different documentation chunks, and an `<Output>` region's first child against the documentation segment at the same index. Both also assert repeatability. Mutations: dropping the chunk index base reddens XP24; dropping the `<Output>` frame reddens both. `<Answer>` expands beneath its own element's frame, but no test proves it — a hand-built region needs a parseable template per answer and the fixture kept collapsing to one matcher. Recorded in the test file and the PR description rather than left implicit. Specification and comments now use #340's registered vocabulary, which merged into main ahead of this rebase.
8ae949d to
d6b04c6
Compare
| // exactly once and here (§5.6). A construct that recurses and a | ||
| // component that expands a body therefore derive identity the same way, | ||
| // and two elements at the same local index under different parents | ||
| // cannot arrive at the same path. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // cannot arrive at the same path. |
| *revParse(revision: string): Operation<string> { | ||
| // `--verify` makes an unresolvable revision an error rather than an echo, | ||
| // and `--end-of-options` stops a revision that looks like a flag from being | ||
| // read as one. The command is an array, so nothing is ever parsed by a shell. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // read as one. The command is an array, so nothing is ever parsed by a shell. |
XP23 builds a positionless `<Answers>` region with two positionless `<Answer>` children, each holding a positionless component that records its expansion and renders the text its answer parses from. Both run, their identifiers differ, and repeating the expansion reproduces both. The fixture needed two things the earlier attempt lacked: a template child that returns valid text, since an empty template is refused and the region stops at the first answer, and a `value` prop that is JSON, since a bare word fails to parse. Neither is about identity, which is why the boundary looked untestable. Removing the frame `readAnswer()` passes makes XP23 fail — checked with an explicit match assertion, after a silent no-match replace made an earlier mutation run report a false negative.
| *revParse(revision: string): Operation<string> { | ||
| // `--verify` makes an unresolvable revision an error rather than an echo, | ||
| // and `--end-of-options` stops a revision that looks like a flag from being | ||
| // read as one. The command is an array, so nothing is ever parsed by a shell. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // read as one. The command is an array, so nothing is ever parsed by a shell. |
Why
Provider-backed Workspace restoration (#218), durable workflow effects (#291), and workflow-owned worktrees (#293) need two
identities the engine could not supply: a deterministic identity for one logical
evaluation of an authored element, and a workflow run tied to one starting
commit chosen once. Closes #289.
What changes
Before:
The only run-scoped identifier was the block counter, which advances with
however much has already run — so it moves under retry and under a branch that
did or did not execute.
invocation()answered the other half of the question,but only for function components, and only as a Component Api operation any
middleware could intercept, while four families of durable record name derive
from it. Nothing associated a run with a repository state.
After:
getExpansion()replacesinvocation()with no alias and covers Markdowncomponent expansions too, answering
{ id, name, position? }. A new@executablemd/workflowpackage associates one document execution with aWorkflowRunwhose base is resolved to a pinned commit once and recordeddurably before the root document is imported.
How it works
An expansion id is derived from the root document and the structural path that
reached the element. Every element that expands descendants — structural
construct and component alike — contributes its frame exactly once, and loop and
item frames extend that element path; the path is threaded explicitly as the
digest so far, so extending it costs one hash. An element is placed by its own
source path and offset rather than by an index into the list being expanded,
which is what makes the derivation independent of how much ran before it, and
what carries the root document's identity — every element in a root's body
reports that document's path, so the root needs no frame of its own.
useWorkflow({ base })installs middleware and nothing else. A journal hasthree states and two middlewares cover them: live, where
Execution.documentallocates the run id, resolves
${base}^{commit}and records the value beforethe root import; truncated, where a
ReplayGuardcheck restores the value andthe durable operation still runs so the cursor advances past its own entry; and
completed, where
durableRunreturns the recorded root result without invokingthe workflow at all — so the check phase, which runs before that shortcut, is
the only place a completed journal can restore its run or refuse a different
base.
Review guide
Start with:
specs/executable-mdx-spec.md§5.6 andspecs/workflow-spec.mdThen review:
packages/core/src/expansion.ts— the contract and how the value is publishedpackages/core/src/expand.tsandexecute.tspackages/workflow/src/{git,journal,run}.tsinvocation()call sitesLook carefully at:
What must stay true
elicit:,prompt:,test:,testing:and the web-form record names are byte-identical and existingjournals still replay — checked by the existing journal suites.
under a process boundary that throws for any command.
checked by XP12, which reads through an independently constructed descriptor.
How to verify it
source, two runtime values for the
<If>condition. Substituting the blockcounter fails it.
parents differ. Reverting
<If>to recurse on the parent path fails it.dispatch: the two arms of one
<If>, template children under two<Answer>elements, positionless elements in different documentation chunks, and an
<Output>region against the documentation segment at the same index. Eachreddens when its frame — or the chunk index base — is removed.
execute(): two root documents differ, one root reproducesits identifiers, and a truncated replay derives the ones already recorded.
the completion schedule across two runs; the ids do not move. XP17 proves
a projection operation constructed and never interpreted consumes no ordinal.
private-field brand on the context value passed every Deno check while the
binary failed 2 of 10 documents, because a repository
.tscomponent importscore from disk while the binary carries its own copy.
cancelling an execution does not erase a recorded run, and that a descriptor of
the same name built elsewhere reads the run.
ReplayGuardfails only the threecompleted-journal tests. Dropping the base comparison fails only WR5.
Reverting the structural-parent frame fails only XP18. A root-document frame
was removed after a mutation showed it inert — with
rootPath = ""every teststill passed, because element positions already carry the root's path.
Replacing the live run-id allocation with a throw reddens live initialization
while the hand-seeded replay tests WR13/WR14 stay green — the evidence that
replay allocates no identifier and invokes no Git, with no test seam in
production code.
Scope
Included
Expansion/getExpansion(), replacinginvocation()without an alias@executablemd/workflow:WorkflowRun,useWorkflow(),getWorkflowRun(),Git.revParse()Intentionally unchanged
xmd runand every existing durable record namepackages/core/src/invocation.tsxmd workflow run/xmd workflow continue, durable lookup and artifacthistory (Store WorkflowRuns and filtered journals in SQLite #291), provider-backed Workspace restoration (Implement retained Workflow Workspace restoration #218), worktrees (Add named Repository and Worktree composition #293),
deterministic Git and GitHub effects — the
CLI inventory rows stay
defined, unbuiltNew abstractions
Expansionexists because durable effects need a deterministic identity perlogical element evaluation; consumers are Implement retained Workflow Workspace restoration #218, Store WorkflowRuns and filtered journals in SQLite #291, and Add named Repository and Worktree composition #293, and it already
replaces
invocation()for seven call sites.GitApiexists because workflow initialization must resolve one revisionwithout core depending on Git; it has one method and one default provider.
New dependencies
@executablemd/workflowdepends on@executablemd/runtimeexec()andcwd()the CLI-backed Git provider invokesreaching
@effectionx/processdirectly would bypass the contextual workingdirectory. Add contextual workflow runs with pinned commits #289 names all three dependencies.
Generated or mechanical changes
.github/workflows/publish-packages.ymlcomes fromdeno task gen:publish-workflow.deno.lock,pnpm-lock.yamlandbun.lockcarry the new workspace member.invocation()→getExpansion()call sites are a type-and-importswap with no behavior change.
Risks and limitations
taken when the projection operation is interpreted and before it suspends, so
it follows program order rather than completion order; XP16 and XP17 pin that.
getExpansion()andgetWorkflowRun()are context values under stable names,so a descriptor built independently — as a separately loaded copy of the
package builds one — reads the same binding. By the same property a descendant
may rebind either name for its own descendants, so neither is an authority
boundary: security enforcement and durable identity never trust replaceable
context state (
architecture.md, State across loaded copies). An earlierrevision claimed otherwise; the specification and the Tier XP rows now say
what the tests actually prove.
packages/core/tests/config-api.test.tsfails under local Node 26 with apost-test async teardown artifact. It reproduces identically on
origin/mainwith none of these changes; CI runs Node 22.
Scope confirmation