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:
- fresh — written after this dispatch started;
- non-empty;
- 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:
- accepts many
reportsartifacts namedlens-*.md; - derives a deterministic seed from the run ID and input hashes;
- scrubs known source markers, shuffles the reports and emits
Response AthroughResponse Nin one markdown bundle; - writes the label map as a
system_onlyartifact.
Migrating a recipe¶
- Add
outputsto each current producer, keeping its existing path at first. Planner, reflector and rollback nodes cannot declare outputs until they gain a publication handler. - Add
inputsto a consumer and connect them with ported edges. - Move filename-specific prompt instructions into the artifact input manifest.
- Insert a
transformnode wherever the handoff must redact, aggregate or otherwise change visibility. - 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.
Related¶
- Writing a recipe — the three-file contract.
- Verification stack — what happens after a node publishes.
- Worktrees and scope — where published files land.