Skip to content

fractal: bundle and render program missions in the tree loader - #6304

Open
schickling-assistant wants to merge 9 commits into
mainfrom
schickling/2026-11-10-program-loader
Open

schickling-assistant wants to merge 9 commits into
mainfrom
schickling/2026-11-10-program-loader

Conversation

@schickling-assistant

Copy link
Copy Markdown
Contributor

Problem

Fractal.mission(intent, program) (fractal/effect, #6266) defines a program mission, but nothing turned one into something the daemon can publish. The engine runs a program step that pins a bundle by sha256 (docs/st3/program-engine.md §3, §4), and fractal apply carries that bundle (#6264). The package's tree loader refused program modules: a program mission has no steps, so MissionSchema decoding failed. The data DSL could not express program, authority or concurrent-runs either.

Goal

The M2 publication path, TypeScript side. loadSubjectTree bundles every program mission it finds and loads it as a declaration that renders as KDL. fractal validate and fractal apply then take that KDL with no other step.

Decisions

  • Data DSL grammar, one convention. StepSchema.program takes { bundle, sha256, stalledAfter? } and renders program "/abs" sha256=… stalled-after=…?. As in the native parser, a program step may not have an assignee, gates, products, a produced or used mission, or be a finally step; it also may not have exec. MissionSchema.concurrentRuns renders concurrent-runs max=N. MissionSchema.authority is { publish?, start?, revise?, write? }, each a non-empty list of unique patterns, exact or ending in /*. Mission verbs refuse a mission/ prefix. The block renders in the parser's canonical order (verb, then pattern), so reordering the rules does not change the revision. The native grammar also has cancel, which is not offered here, since a run never cancels another one. The native parser checks write subjects against the schema registry; this package only checks their shape.
  • Loader. loadSubjectTree({ root, context, bundleDir?, bun? }). When a mission.ts default export carries Symbol.for('fractal.program.v1'), the loader does the following:
    • It checks that the declared id equals the directory, and that every input field is required and encodes to a string.
    • It runs bun build <module> --target=bun --format=esm with the tree root as cwd, hashes the output and writes it to <bundleDir>/<sha256>.mjs.
    • It loads the bundle with Bun to check that the bundle's default export carries the entry.
    • It loads the mission as { id, state: 'ready', reportTo, goal, timeout?, concurrentRuns?, authority?, inputs: one text input per field, steps: [{ id: 'program', timeout?, program: { bundle, sha256, stalledAfter? } }] }. That declaration goes through the existing missionWire → MissionSchema path, so emit([mission(intent)]) renders it.
    • A tree with a program mission and no bundleDir, or a relative bundleDir, is refused.
  • Determinism. Bun names inlined modules by their path relative to its cwd, so building from the tree root keeps absolute checkout paths out of the bytes. The bundle inlines Effect, the fractal/effect runtime and the program's own imports. Bundles are content-addressed files, so a rebuild writes the same file.
  • Intent fields. timeout bounds both the mission and its program step. stalledAfter becomes the program's stalled-after, i.e. journal progress. concurrentRuns and authority are new optional fields of the program intent.

Verification

On the Linux build host in nix develop .#web (bun 1.4.2), at head ad1b75d:

$ pnpm --filter fractal typecheck
$ tsc --noEmit                      # clean
$ pnpm --filter fractal test
 ✓ src/program-loader.unit.test.ts (12 tests | 1 skipped)
 Test Files  13 passed (13)
      Tests  496 passed | 8 skipped (504)

The skipped loader test is the ST_BIN native check. It ran separately with a fractal binary built from #6264's branch:

$ ST_BIN=<fractal from #6264 at 9aa1096557> npx vitest run src/program-loader
 ✓ src/program-loader.unit.test.ts (12 tests)
     ✓ validate natively, including the bundle hash

src/program-loader.unit.test.ts contains these tests:

  • The landing fixture (fixtures/programs/factory/land-program) renders byte for byte to fixtures/land-program.kdl, with the bundle directory shown as /bundles. The written bundle's bytes hash to the pinned sha256.
  • Two builds into different directories give the same sha256 and the same bytes.
  • Refusals: no or relative bundleDir; a declared id other than the directory (fixtures/program-refusals/id-mismatch); a number input (fixtures/program-refusals/number-input); a bundle whose default export is not a program; a module Bun cannot build; a missing Bun.
  • The data form: the program step, canonical authority order and concurrent-runs render exactly. The program-step outcome conflicts, a finally program step, a relative bundle path, an uppercase sha256, malformed authority patterns, duplicate rules, an empty block and concurrent-runs max=0 are refused.
  • With ST_BIN, fractal validate accepts the rendered KDL and refuses it once the bundle's bytes change.

The golden is sensitive to the bundle's contents. Running oxfmt on the fixture changed the sha256, and the golden caught it.

Formatting: the changed files pass oxfmt with the effect-utils config. Pushed with ST_EVIDENCE_LOCAL=off; hosted CI at the exact head is the proof.

Complexity

The changes span three areas: the data-DSL grammar in mod.ts (+124), the loader and bundler (tree.ts +125, program-bundler.ts 89), and tests with fixtures. Bundling is a small Node module that spawns Bun, the same Effect.callback spawn pattern testing.ts uses. The loader reuses the existing declaration path rather than adding a new subject variant.

Concerns

  • Golden churn. The golden pins the landing bundle's sha256. Any change to code the bundle inlines changes the golden: the fixture, fractal/effect, the data DSL it imports through agent.ts, Effect, or Bun. FRACTAL_UPDATE_GOLDENS=1 pnpm --filter fractal test rewrites it, and the README says so.
  • Cross-environment stability. It depends on the same Bun (pinned by the nix shell) and the same lockfile layout. Paths in the bundle are relative to the tree root (../../../../node_modules/effect/...). A different Bun version produces different bytes and therefore a new revision. That is correct, but worth knowing.
  • Bun is required. A program tree needs bun (on PATH or via the bun option) at render time. Trees without program missions are unaffected.
  • Bundle size. The landing bundle is about 350 KB, because it inlines Effect and the data DSL that agent.ts pulls in for reportTo.

Friction & bottlenecks

Follow-ups

References

…e data DSL

StepSchema.program renders `program "/abs" sha256=… stalled-after=…?`
and refuses an assignee, gates, products, exec or a produced or used
mission, and finally steps. MissionSchema gains `concurrentRuns` and an
`authority` block (publish, start, revise, write) rendered in the
native parser's canonical order.
loadSubjectTree({ bundleDir }) bundles each Fractal.mission(intent,
program) with bun build --target=bun from the tree root into
<bundleDir>/<sha256>.mjs, checks the bundle's default export carries the
program entry, and loads the mission as its declaration: one text input
per input field and one program step pinning the bundle. The landing
fixture renders against a golden; builds are deterministic.
@schickling-assistant
schickling-assistant marked this pull request as ready for review November 10, 2026 18:31
…-program-loader

# Conflicts:
#	packages/fractal/COVERAGE.md
#	packages/fractal/README.md
#	packages/fractal/src/tree.ts
@schickling-assistant

Copy link
Copy Markdown
Contributor Author

Delta review record for head d3e26f638b8d11cab0ec19f16d07ef6f6933a688.

  • Independent reviewer: GO at 496c0f515a (earlier rounds recorded above).
  • Delta at d3e26f638b8d11cab0ec19f16d07ef6f6933a688 (fresh independent reviewer): GO, no findings. The commit merges main (with Program engine M1.1a: declare program steps and carry their bundles through apply #6264 at 0f018a1b57 and main's objective subject trees). Conflicts in packages/fractal/src/tree.ts, README.md and COVERAGE.md were hand-resolved by keeping both sides: programMission/programDeclaration from this PR plus main's loadObjective, objective dispatch and docs. Against main the diff holds only this PR's changes; against 496c0f515a only main's objective changes. The program branch runs only for mission definitions, so objectives never reach it.
  • ST_BIN rerun as required: fractal validate accepts the rendered KDL and refuses it after the bundle bytes change, against a fractal built from main 4e4932bb83 (contains Program engine M1.1a: declare program steps and carry their bundles through apply #6264) on dev3 (validate natively, including the bundle hash ✓). tsc --noEmit is clean on the merged tree. The landing golden-sha test failed locally only because that run borrowed another checkout's node_modules; it fails the same way at 496c0f515a. Hosted CI at this head is the proof.
  • Risk medium. Queue alone.
  • The owner pre-approved queueing this effort's high-risk pull requests on an independent GO plus green CI.
  • Verdict: GO; queue alone on hosted CI green at this exact head.

@schickling-assistant

Copy link
Copy Markdown
Contributor Author

Delta review record for head ff2863326e1f562cfbe3e2acc677c1e29ec27c64.

  • Hosted typescript-client failed the landing golden at d3e26f638b: the bundle includes packages/fractal sources that main changed, so it now hashes to 1fbec08c0d…. One new commit repins fixtures/land-program.kdl (path and sha256) from 96c0ea71e3… to the CI-observed hash; nothing else references either hash.
  • Independent reviewer delta: GO, no findings; the merge review of d3e26f638b still holds. Risk medium; queue alone.
  • The owner pre-approved queueing this effort's high-risk pull requests on an independent GO plus green CI.
  • Verdict: GO; queue alone on hosted CI green at this exact head.

This branch is being deployed

1 in progress deployment
evidence-signing — ff286332 Deployed Nov 11, 2026 by schickling via linux-tests-shard-2 #19757
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.

2 participants