Skip to content

💥 Add expansion identity and pinned-commit workflow runs - #341

Merged
taras merged 9 commits into
mainfrom
docs/issue-289-architecture
Aug 6, 2026
Merged

💥 Add expansion identity and pinned-commit workflow runs#341
taras merged 9 commits into
mainfrom
docs/issue-289-architecture

Conversation

@taras

@taras taras commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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() replaces invocation() with no alias and covers Markdown
component expansions too, answering { id, name, position? }. A new
@executablemd/workflow package associates one document execution with a
WorkflowRun whose base is resolved to a pinned commit once and recorded
durably before the root document is imported.

How it works

root document → structural path per frame → expansion id
useWorkflow({base}) → first durable operation → pinned commit → root import

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 has
three states and two middlewares cover them: live, where Execution.document
allocates the run id, resolves ${base}^{commit} and records the value before
the root import; truncated, where a ReplayGuard check restores the value and
the durable operation still runs so the cursor advances past its own entry; and
completed, where 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 different
base.

Review guide

Start with: specs/executable-mdx-spec.md §5.6 and specs/workflow-spec.md

Then review:

  1. packages/core/src/expansion.ts — the contract and how the value is published
  2. The frame plumbing in packages/core/src/expand.ts and execute.ts
  3. packages/workflow/src/{git,journal,run}.ts
  4. The seven migrated invocation() call sites

Look carefully at:

  • Whether any derivation can be reached by a counter, a clock, or scheduling order.
  • The projection ordinal — the one discriminator not read from source.
  • That replay allocates no identifier and invokes no Git.

What must stay true

  • 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 — checked by the existing journal suites.
  • Core never imports workflow or Git — checked by WR9, which runs a document
    under a process boundary that throws for any command.
  • A contextual value is readable by a second loaded copy of the package —
    checked by XP12, which reads through an independently constructed descriptor.

How to verify it

  • XP3 proves an element's id does not depend on how much ran before it: one
    source, two runtime values for the <If> condition. Substituting the block
    counter fails it.
  • XP18 proves two elements at the same index under different structural
    parents differ. Reverting <If> to recurse on the parent path fails it.
  • XP22–XP25 cover the consumed parents that never reach expansion's
    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. Each
    reddens when its frame — or the chunk index base — is removed.
  • XP19–XP21 drive execute(): two root documents differ, one root reproduces
    its identifiers, and a truncated replay derives the ones already recorded.
  • XP16 starts two projections of one slot, blocks the first, and reverses
    the completion schedule across two runs; the ids do not move. XP17 proves
    a projection operation constructed and never interpreted consumes no ordinal.
  • XP12 would have caught the compiled-binary break in the fast suite: a
    private-field brand on the context value passed every Deno check while the
    binary failed 2 of 10 documents, because a repository .ts component imports
    core from disk while the binary carries its own copy.
  • WR15–WR17 pin that expansion ids do not move with the workflow run, that
    cancelling an execution does not erase a recorded run, and that a descriptor of
    the same name built elsewhere reads the run.
  • Mutation evidence. Removing the ReplayGuard fails only the three
    completed-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 test
    still 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(), replacing invocation() without an alias
  • @executablemd/workflow: WorkflowRun, useWorkflow(), getWorkflowRun(),
    Git.revParse()
  • Specification and architecture inventory updates

Intentionally unchanged

New abstractions

New dependencies

  • Package: @executablemd/workflow depends on @executablemd/runtime
  • Used for: the contextual exec() and cwd() the CLI-backed Git provider invokes
  • Why existing dependencies are insufficient: core does not re-export either, and
    reaching @effectionx/process directly would bypass the contextual working
    directory. Add contextual workflow runs with pinned commits #289 names all three dependencies.

Generated or mechanical changes

  • .github/workflows/publish-packages.yml comes from
    deno task gen:publish-workflow.
  • deno.lock, pnpm-lock.yaml and bun.lock carry the new workspace member.
  • The seven invocation()getExpansion() call sites are a type-and-import
    swap with no behavior change.

Risks and limitations

  • The projection ordinal is the one discriminator not read from source. It is
    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() and getWorkflowRun() 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 earlier
    revision claimed otherwise; the specification and the Tier XP rows now say
    what the tests actually prove.
  • Stable context names are compatibility contracts.
  • packages/core/tests/config-api.test.ts fails under local Node 26 with a
    post-test async teardown artifact. It reproduces identically on origin/main
    with none of these changes; CI runs Node 22.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 3 redundant comments. Inline suggestions to remove them below.

Comment thread packages/core/src/expand.ts Outdated

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// does, so re-expanding the same element arrives at the same identifier.

Comment thread packages/core/src/expand.ts Outdated

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// does, so re-expanding the same element arrives at the same identifier.

Comment thread packages/workflow/src/run.ts Outdated
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// Keeping that object is what makes every read in one execution the same one.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR #341: 💥 Add expansion identity and pinned-commit workflow runs

36 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.
Symbol Declared at Refs in diff Why flagged
ExpandSegments packages/core/src/answers.ts:94 1 referenced ≤1× within the added diff (pre-existing usages not counted)

Slop

  • packages/workflow/src/git.ts:42// read as one. The command is an array, so nothing is ever parsed by a shell.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

taras added a commit that referenced this pull request Aug 5, 2026
… 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.
taras added 8 commits August 5, 2026 17:21
## 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.
@taras
taras force-pushed the docs/issue-289-architecture branch from 8ae949d to d6b04c6 Compare August 5, 2026 21:34

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 redundant comments. Inline suggestions to remove them below.

// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// 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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 redundant comment. Inline suggestions to remove them below.

*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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// read as one. The command is an array, so nothing is ever parsed by a shell.

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 contextual workflow runs with pinned commits

1 participant