Skip to content

Artifact graph

A node that "finishes" has not necessarily produced anything a downstream node can use. The artifact graph makes the handoff explicit: producers declare the named files they publish, consumers declare the named artifacts they require, and an edge with both from_output and to_input is the only valid handoff. The engine owns the contract around the harness; the harness still does the work.

The three declarations

A workflow node may declare:

Field Meaning
outputs Named files the node must publish after a successful run
inputs Named artifacts the node requires before it may start
strict_handshake true disables artifact-based completion for this node

An edge that carries both from_output and to_input connects exactly one producer port to one consumer port:

nodes:
  - id: researcher
    type: researcher
    outputs:
      - name: findings
        path: workspace/findings.md
        visibility: consumer

  - id: implementer
    type: implementer
    inputs:
      - name: findings
        from: researcher.findings

  - id: verify
    type: verifier
    depends_on: [implementer]

The compiler validates the graph before anything runs: duplicate port names, required inputs that nothing produces, visibility violations, output paths claimed by two nodes, and DAG readiness. A consumer never starts merely because its turn came up.

Compile-time and run-time checks

flowchart LR
  subgraph compile["Compiler"]
    D1["duplicate ports"] --> D2["unbound required inputs"]
    D2 --> D3["visibility mismatch"]
    D3 --> D4["output path owned twice"]
    D4 --> D5["DAG readiness"]
  end
  subgraph run["Executor"]
    R1["all parents succeeded"] --> R2["declared outputs published"]
    R2 --> R3["hashes verified"]
    R3 --> R4["materialize into consumer inputs"]
  end
  compile --> run

At run time an edge is also a completion gate. Independent ready nodes may run in parallel, but a consumer starts only after every parent succeeds and publishes every declared output. A failed parent blocks its descendants, including publishers. Recipes that declare no ports stay on the legacy node-order path, so adopting the graph is incremental.

Artifact-based completion

For researcher and implementer nodes, success is decided from the declared outputs, not from the agent's self-report. If dispatch exits non-zero — a watchdog rc=124 after the files landed, for example — the node still completes when every declared output is:

  1. fresh — written after this dispatch started;
  2. non-empty;
  3. JSON-parseable, when the name ends in .json.

The executor logs [ok] node completed via artifact check (self-report missing/invalid) and records a node.artifact_completion event. Missing, empty, invalid or stale outputs keep the original failure reason (timeout, error, cost_limit). An implementer that completes this way never applies its possibly truncated stdout diff.

Outputs default to the node's outputs; a researcher without them falls back to its conventional output file, and a recursive-validate-impl implementer to implementer-summary.json. Any other implementer with no outputs gets no artifact completion. Verifier, publisher, rollback, reviewer, eval and transform nodes are unaffected.

Per-kind completion contracts

The generic checks cannot distinguish a well-formed fabrication from a real deliverable. A registry of per-kind contracts adds the shape each known artifact must have. When a declared output's basename matches a contract, that contract must also pass.

Basename Contract
implementer-summary.json Claimed files (touched_files / files_changed) non-empty, and each relative claim exists under the repo root and was modified at or after the dispatch-start marker. An empty claim list passes only as an honest abstention: ready_for_tier1 exactly false and a non-empty block_reason
plan.json A JSON object with a non-empty list of object steps
*.diff, *.patch At least one --- / +++ header pair

Unknown basenames keep exactly the generic checks. A failure reason keeps the <path>: ... shape and names the contract, for example:

workspace/implementer-summary.json: implementer-summary.json contract:
files_changed/touched_files is empty

The check fails on the first violating path. The node.artifact_completion payload carries an additive contracts map ({"implementer-summary.json": "pass"}); the rc, suppressed_finish_reason and artifacts keys are unchanged.

Register a new kind without touching the dispatcher:

from mini_ork.execute_compat import register_completion_contract

def check_my_report(path, since_mtime):
    # return a reason string to fail, or None to pass
    return None

register_completion_contract("my-report.json", check_my_report)

Run layout

For a run directory <run>, the ledger writes:

<run>/
  workspace/
    manifests/<node>.outputs.json
    manifests/<node>.inputs.json
    inputs/<consumer>/<input-port>/...
    system/<transform>/...              # system-only receipts
    scratch/<transform>-<seed>/...      # transform working files

An output manifest records the declared relative path, byte size, SHA-256, kind and visibility. Before materializing an input, the ledger verifies the source file still matches its published hash. A producer that does not write a declared output fails with artifact_contract.

workspace/manifests/** and workspace/inputs/** are reserved for the ledger; recipes cannot overwrite its integrity metadata. workspace/system/** is valid for declared system_only receipts.

Visibility

Visibility Who may consume it
consumer (default) Any declared consumer input bound to it
system_only Only a deterministic transform node; operators may inspect it

system_only is how a receipt such as a panel label map stays visible to the operator while remaining unreachable by an LLM synthesizer.

Application-level boundary, not OS isolation

The ledger is a capability boundary enforced by the engine. A local harness with unrestricted filesystem access could ignore its prompt and read other run files. Use the configured bubblewrap runtime or an equivalent per-node mount policy when strict filesystem isolation is required.

Transforms

Transforms are registered Python functions, not LLM nodes. They receive only compiler-prepared inputs and must publish their declared outputs. This is the shared wiring point for anonymization, redaction, normalization, extraction or packaging — anything that must happen deterministically between two agents.

panel.anonymize@v1 is the reference implementation:

  1. accepts many reports artifacts named lens-*.md;
  2. derives a deterministic seed from the run ID and input hashes;
  3. scrubs known source markers, shuffles the reports and emits Response A through Response N in one markdown bundle;
  4. writes the label map as a system_only artifact.

Migrating a recipe

  1. Add outputs to each current producer, keeping its existing path at first. Planner, reflector and rollback nodes cannot declare outputs until they gain a publication handler.
  2. Add inputs to a consumer and connect them with ported edges.
  3. Move filename-specific prompt instructions into the artifact input manifest.
  4. Insert a transform node wherever the handoff must redact, aggregate or otherwise change visibility.
  5. Add a focused test for topology, missing/tampered artifacts and the consumer prompt boundary.

recipes/refactor-audit/workflow.yaml is the reference migration: it binds five lens reports to anonymize_panel.reports and binds only panel_responses to synthesizer.panel_reports.