0021: Workflows are Markdown contracts with YAML frontmatter¶
Context and Problem Statement¶
Teams add workflows by writing files, not code (ADR 0010). OpenProse's contract Markdown gives us the vocabulary we
want: ### Goal, ### Requires, ### Maintains with #### facets, ### Continuity, ### Invariants,
postconditions instead of a judge. But OpenProse keeps frontmatter to identity only (name, kind, id,
version), forbids tools in frontmatter, and wires contracts through a compile step that runs model sessions
(Forme). ADR 0010 promised "a subset of OpenProse contract Markdown"; taken literally, that leaves the kernel nothing
typed to read. What is the file format of a workflow?
Decision Drivers¶
- The daemon head boots the kernel on every tick, and without calling a model it must decide which jobs are due, compute memo keys, validate approvals, and list contracts through CLI, MCP and REST (ADR 0020).
- Comparable formats split the same way: GitHub Actions, Kestra, dbt and CWL put triggers, inputs, limits and references in typed YAML. Agent Skills, Claude Code skills, Prompty and static-site generators put a small YAML block over a Markdown body, and keep tool-specific keys in a single map. Dagster and Prefect show that caching works only when the definition states exactly what is hashed.
- Embedded workflows (a "🧝 Heinzel" tab) need a text form that survives rich editors and can be pinned by fingerprint.
Considered Options¶
- OpenProse-compatible Markdown body with YAML frontmatter, hz keys nested under
hz:(chosen) - Pure OpenProse (identity-only frontmatter, everything in sections, Forme wiring): needs a model to compile and wire, which puts a model on the scheduling and safety path, and gives the kernel nothing typed to expose.
- Hz keys at the top level of the frontmatter: flatter to write, but mixes namespaces, makes OpenProse tooling meet many unknown keys, and blurs which keys are ours.
- Pure YAML workflows (GitHub Actions, Kestra, CWL style): typed and familiar, but the goal, materiality and postconditions become string blobs. They are poor to read and review, and break with Reactor's "Markdown is intent".
- Python-only
JobLets: they remain available for code-heavy jobs, but they exclude non-developers (tenet 2) and cannot be embedded in documents. - TOML frontmatter (
+++): consistent withhz.toml, but unfamiliar in Markdown tools, unsupported by OpenProse and skill tooling, and awkward for nested lists. - JSON workflow graphs (n8n style): diff and review badly; no place for prose intent.
- Semantic matching of Requires to Maintains: convenient, but not deterministic. Kept only as a suggestion
tool (
hz workflow graph --suggest) that proposes explicit edges by pull request. - Hashing the whole file for the memo key: simple, but a typo fix, a new tag or a cron change would re-render
everything and wake every downstream contract. The whole-file hash is kept only as
source_fingerprintfor pinning. - Reactor as a runtime dependency: alpha and TypeScript (ADR 0010, ADR 0020). We keep the body dialect compatible instead.
Decision Outcome¶
Chosen option (proposed): a workflow is a <name>.prose.md file with YAML frontmatter, hz keys under hz:, and
an OpenProse-compatible Markdown body, because the kernel gets typed data without a model while the intent stays
readable prose. Each point says what 0.2 builds and what comes later.
- File and parser. A workflow is
<name>.prose.md: YAML frontmatter between---lines, then a Markdown body;nameequals the file stem (HZC010). - 0.2: PyYAML reads it, with a loader that keeps only YAML 1.2 core scalars, so
on,yes, dates and1:20stay strings, and numbers with a leading zero stay strings too. Duplicate and non-string keys, anchors and aliases, explicit tags other than the core scalar ones, nesting deeper than 32, more than 10,000 values and files over 256 KiB are refused before anything is built, and so is a file holding control characters other than tab and line feed (HZC005): escape sequences in a title or a diagnostic would reach whoever reads it on a terminal. Any failure is that file's diagnostic and never stops the others or the daemon. - Later: a rewriting operation (
migrate,validate --fix) needs a writer that keeps comments and layout, such as ruamel.yaml's round trip; reading doesn't. - Frontmatter. The top level is OpenProse-compatible:
name(lowercase words joined by single dashes, ≤64; a subset of the kernel's job names),kind: responsibility,description(≤1024), optionalidandversion;x-*keys are ignored. Every hz key is underhz:; keys that exist onJobLetkeep its names. - 0.2:
schema: 1,title,scope(personal),schedule(five cron fields or a shortcut such as@daily; none means by hand only),requires,inputs,outputs(exactly one),publishes,agent(tools,max_usd),timeout(the agent session, ≤2h, default 10m),retries(0 to 3) andcatch_up(latestorskip). - Later, refused until then with
HZC023(all such keys reported at once):owner,maintainers,tags,freshness,on,tools,concurrency,cost,idempotency,overrides,scope: team,catch_up: all, outputtargetand facets, inputsource/query/facets/required, publishauto_approveandtarget. Agentrunner,model,effortandmax_turnsare refused for good: the runner and model are configuration (ADR 0029). - Amended 2026-10-24 (ADR 0049
statements 13, 15 and 20, borrowed from OpenProse Reactor,
harness spec,
pattern): four keys are defined.
They stay refused with
HZC023until ADR 0049's staged step 6 builds them;freshnessstays deferred.- Input
facet(singular; it replaces the deferred inputfacets, and two facets are two inputs): one facet of what a capability input reads, a document's tab or heading, a tracker's row; the input then wakes only when that facet's fingerprint moved (8). Acontract:input keeps its#<output>suffix (7) and takes nofacet. - Input
recheck(5mto7d, default every tick): how often the daemon may look, so a volatile input is polled often and a roster weekly. It decides when to look and never wakes anyone by itself. - Output
valid_for(1hto366d): the approved text expires that long after approval, and the Heinzelmännchen then wakes with causestale. hz.render_on_schedule: if-moved | always: whether a schedule slot renders when no input moved.if-movedgathers, fingerprints and endsskippedwith reasonnothing-movedat no model cost;alwaysrenders every slot, for an output that must exist each time ("the Friday section, even if nothing merged"). An absent key meansalways, so no existing contract changes behaviour;hz heinzel newwritesif-movedinto the personal contracts it creates, andif-movedis refused for team contracts until ADR 0054 records how a skip counts towards a promise.
- Input
- Requirements and inputs name capabilities, never vendors (ADR 0028).
- 0.2:
requireslists<capability>:read;agent-runner:readis implied; a contract never pins an adapter and never writes. An input is{read: <capability>.<method>, args, window}, checked against the protocol method's signature. Its window looks back from the run (7d,last 7d: it ends when the run starts) or ahead of it (next 7d: it begins then, e.g. a calendar's coming week), at most 366 days either way (amended 2026-10-24, maintainer's decision: "a forward window is just an input window"). Its data is hashed like any other input's. - Later: windows end, or begin, at the schedule slot, with memo keys (8).
- Body. Required:
### Goal,### Maintainsand### Done when(with items);### Requireswhen there are inputs. Optional:### Continuity,### Steps,### Invariants,### Description. Retired OpenProse sections and### Executionare rejected (HZC106); unknown sections are warnings (HZC105). HTML comments are authors' notes, never shown to the agent. - 0.2: backticked items must match the keys of
hz.inputsandhz.outputs(HZC103);### Toolsis "not yet" (HZC023) whilehz.toolsis deferred. - Later:
####facets with Material and Immaterial notes checked against output facets. - Models and schema. Pydantic models with described fields are the source of truth; the JSON Schema is
generated as
docs/schemas/contract-frontmatter-<n>.json,make checkfails on drift, and a test validates the example against it. Diagnostics carry file, line, key, a stableHZCcode and a hint, also as JSON. - Later: migration functions for
hz.schema2 andhz workflow migrate. - Compilation. Contracts are data, so there is no
ContractLet. Theworkflowmodule is the single compiler of every contract source (files now; plugin-shipped and embedded contracts later are discovered by it too). It provides the kernel'sCompiledJobsservice; the kernel calls its factory afresh whenever jobs are listed, and the daemon runs them beside the declaredJobLets asworkflow:<namespace>-<name>, some:xandteam:xare two jobs. Each job acts as aRequesterof kindjobof that module, built from the contract, and gets handles throughKernel.handle_for, which serves only the compiling module's jobs, read only and without adapter pins; compiled jobs appear in the capability matrix. A failing compiler is reported and the declared jobs still run. - 0.2: one bounded agent session per run, inputs pre-fetched, no hz tools.
- Later: inputs compiled into shared
WatchLetinstances;JobLetgainsfingerprint,freshnessandcost. - The graph. Edges come only from explicit
contract:<namespace>/<name>#<facet>inputs, never semantic matching; downstream contracts see published truth unless they declarestage: draft; cycles are reported and disable the contracts on them; team contracts can't depend on personal ones. - 0.2 (amended 2026-10-24): an input
{read: "contract:<namespace>/<name>#<output>", stage: draft}reads the newest local draft of that contract's output (its text, or its JSON), hashed like any input; it takes noargsorwindowand needs no capability.stage: draftis required, since nothing is published yet.hz workflow graph(text,--json,--mermaid) derives the graph from the contracts found: contracts, the assets they maintain, the sources they read, and edges between them. A contract on a cycle, its own output included, is an error (HZC030) and doesn't run; an input no contract maintains is a warning (HZC031) and fails that run; a team contract reading a personal one's output is an error (HZC032). These are diagnostics of the reading contract, found only when the whole set is read, not bycheck --path. - Later: published truth as the default stage, output facets in place of output names, and
--suggest. - Fingerprints (later). All are SHA-256 over RFC 8785 canonical JSON of normalized text.
contract_fingerprintcovers intent only (kind, inputs, outputs, publish actions and targets, tools, one hash per behavior-bearing section), computed from the migrated model with defaults filled in; it excludes identity, display, scheduling, agent hints, limits, policy references,### Continuityand comments.source_fingerprintcovers the exact canonical text and pins embedded workflows. The memo key is(contract_fingerprint, input facet fingerprints, freshness_epoch). - Amended 2026-10-24 (ADR 0049): an
input's fingerprint is a map
{facet: hash}, computed by hz over the canonical content per facet the adapter returns (ADR 0028); an adapter that knows no facets returns one facet,@all, the whole result. An input withfacet(2) compares only that facet, so a change in another tab of the same document wakes nobody who reads this one. The freshness epoch advances only when an approved output'svalid_until(approval plusvalid_for, 2) passes, and the wake it causes has causestale; arecheckinterval moves nothing in the key. As in Reactor, nothing in the memo key is judged by a model: facets and what is material are decided by adapter code, never by a model compiling the contract. - 0.2: each input's data is hashed in the render's record; the whole file's SHA-256 pins team contracts (10).
- Approvals and cost. Every
publishesentry names a publish operation (read_only=False,HZC013otherwise), which declareswriteitself; the contract never does. - 0.2: nothing is published; drafts are saved locally and wait for
hz inbox. Every run is capped by[workflow] max_usd(user scope, default 1.0 USD) and the contract's ownagent.max_usd, whichever is lower; a scheduled slot splits that cap evenly across its attempts (1 + retries), so the slot never spends more. - Later: approvals in the state repository;
auto_approvenames a policy but grants nothing, and is ignored after an intent change and for corrections. - Locations and trust. Qualified names are
<namespace>:<name>; a bare name that exists in two namespaces is an error that asks for the prefix, in every head.- 0.2:
meis[workflow] user_dir(default<config dir>/workflows),teamisworkflows/in a checkout of the team's state repository ([workflow] team_repository). A projecthz.tomlcan't setuser_dir,team_repository,schedule_teamormax_usd, and doctor names the keys it tried: a repository someone else controls must not decide which prompts your daemon runs or what they cost. A contract is data: it gets read handles for what it declared through the adapters configuration binds. Symbolic links are skipped. Team contracts run by hand, and on your daemon only when[workflow] schedule_teamnames them, optionally pinned asname@sha256:<hex>; a pinned contract whose file changed doesn't run, and doctor says so. - Later: project contracts in
.hz/workflows/(project, personal scope only), module and plugin contracts (namespace = module), embedded registered contracts (doc),hz.overrides, and team scope with leases.
- 0.2:
- Embedding (later). The canonical text is the file text. A leading fenced
yamlblock is accepted in place of---, and headings are matched relative to the shallowest level. Both forms are converted to the canonical form before fingerprinting; this must be one function with ADR 0022'scanonical-v1. - Operations.
- 0.2:
hz workflow list,show <name>(schedule, daemon job or why there is none, requirements with their adapters, inputs, outputs, publishes, limits, the SHA-256 to approve, diagnostics),check [--path](exits with 1 on errors, for a team's CI; a path must be inside your workflows directory, the team repository or the current directory, because every head exposescheck) andrun <name> [--dry-run](CLI and MCP only: a render spends money, and a web page can reach a local REST server, ADR 0019). Whatcheckaccepts compiles: the schedule is checked with the kernel's own cron shape check, and a contract the kernel would refuse as a job is invalid inchecktoo; compiling one contract never stops the others. A manual run saves the draft and a.record.jsonbut no ledger receipt and says so; receipted runs go through the daemon (hz system daemon tick --job workflow:<namespace>-<name>). - 0.2, the success measure of a drafting contract (maintainer's validation slice, 2026-10-24): each render
records its drafting time;
sent <name> --file <f>or--ref <document>(the approved version where the asset lives, read throughdocuments) records the share of words changed, both word counts and a hash, never the text, next to the draft;showreports both per draft. It is about drafts, never about people. - 0.2,
graph(text,--json,--mermaid; every head, read-only, likecheck: it lists contracts and what they read, never fetched data) draws the graph of (7).newwrites a starter contract. - Later:
fingerprint,migrate,export --prose.
- 0.2:
-
Retention (amended 2026-10-24, maintainer's decision: "Agreed: 90 days default, configurable."). Drafts and their
.record.jsonfiles, which hold the packet with the fetched inputs, expire[workflow] retention_daysafter their render (default 90; 0 keeps them forever; 0 to 36,500; a projecthz.tomlcan't set it). Receipts don't expire.- Deleting is housekeeping, never a read.
hz workflow prune [--dry-run](CLI and MCP: it deletes your files and a dry run names them) and the daily jobworkflow:prunedelete them; it is an ordinaryJobLet, so the daemon's tick runs it with a receipt that counts what went and names no file (ADR 0020, decision 10).drafts,show,listand the health check never delete;hz system doctorsays how muchprunewould delete, and degrades once expired files are more than two days overdue (nothing is pruning them; a daily slot a laptop slept through still runs at the next tick). - Only what hz wrote, only where it wrote it. Files named as a render names them (
<stamp>[-n].md,.json,.record.json), in a contract's directory below<data dir>/workflow/drafts/; their age comes from the stamp in the name, not the file's times. The walk opens each directory relative to the last withO_NOFOLLOWand unlinks relative to its descriptor, so a symbolic link, also one swapped in while it runs, can't make it delete elsewhere;workflowordraftsbeing a link is an error. Directories stay (a render may be writing into one); a file already gone is not an error, so pruning twice deletes nothing more. A contract directory or file that can't be read or deleted is reported and the rest are still pruned;prunethen exits with 1 and the job fails. - Receipts are kept, and hold no draft text (chosen over pruning receipt payloads or checkpointing the chain). A receipt's id hashes its content and chains it to the job's previous receipt, so deleting a receipt or its payload would break every later link, and a checkpoint (a signed or recorded summary of the chain so far) is machinery the shared ledger (0.3) should own. Instead, a compiled job's receipt carries the render without the agent's draft text: the draft's path, its SHA-256, the record's path, the input hashes, the agent's status, model, usage and cost. That is lineage without content, small enough to keep, and the chain stays verifiable because nothing is removed. A receipt's draft path can outlive the draft; its hash still identifies it.
- 0.2.0 receipts of compiled jobs hold the draft's text in
result.agent.draft. They aren't rewritten, which would break their chain; moving a job's receipts aside, the documented way to start a chain again, drops them.
- Deleting is housekeeping, never a read.
-
What a contract is, and where the workflow is (amended 2026-10-24, maintainer's decision: "a contract is an invariant about one asset ('keep X true given Y'); the overall workflow is not written anywhere but emerges from the graph of contracts linked by inputs/outputs. Events are just 'an input changed' (fingerprints), besides clock and manual triggers.").
- A contract states one invariant: it keeps one asset, its output, true given its inputs.
### Goaland### Maintainssay what is true;### Done whenis how the agent checks it. - Until publishing exists, the output is the asset's draft: the graph's asset nodes are a contract's outputs
(
contract:<namespace>/<name>#<output>). Where the asset itself lives (ADR 0037's asset types andkept_by, a document a playbook names) is not a node yet; when a contract can name it, the output is drawn as the draft of that asset, and the node keeps its id. - There are no workflow files and no pipeline definitions. The workflow is the graph (7): contracts linked by the
outputs they maintain and the inputs that read them. Adding a step is adding a contract that reads another's
output;
hz workflow graphshows the result. - A contract wakes for three reasons only: the clock (
schedule), a person (hz workflow run,tick --job) and, later, an input that changed, detected by its fingerprint (8) rather than by a separate event system. 0.2 builds the first two; input wakes come with memo keys andWatchLets.
- A contract states one invariant: it keeps one asset, its output, true given its inputs.
Consequences¶
- Good: a team adds a workflow with one reviewable Markdown file. The same text works as a file, in a plugin, or embedded in a document.
- Good: the daemon decides whether to render with pure Python. Changing a cron expression, a model hint or a cost ceiling does not re-render anything; changing intent does, and re-arms human review.
- Good: explicit edges cost authors a line per dependency, but give a graph that can be reviewed and is deterministic.
- Neutral: the body stays readable as an OpenProse responsibility. The frontmatter is an extension under one key,
so ADR 0010's "subset of OpenProse" now holds for the body only. An exporter folds
### Done whenback into Maintains postconditions for OpenProse tooling. - Bad: body and frontmatter can disagree, so validation must check both.
hz workflow check, and later a fixer and agent-drafted pull requests, keep the cost low. - Bad: new work: the parser, the models and the generated schema, the compiler to
CompiledJobs, theHZCdiagnostics and a health check (0.2); the compiler toWatchLet, graph checks and the fingerprint functions with golden tests (later). - Neutral: Material and Immaterial notes are honored by the render in v1. A deterministic canonicalizer from those notes is follow-up work.
Confirmation¶
Partly, for the points marked 0.2 (see Status under More Information): the strict YAML loader and its limits (an
alias bomb and 5,000-deep nesting finish fast), the frontmatter schema, the body checks and the HZC diagnostics with
their lines (tests/unit/modules/workflow_contracts_test.py); discovery, one name in two namespaces, publish and tool
checks, compilation into daemon jobs, the cost cap and its split, pinned team contracts, the packet with inputs
fetched through capability handles and its size caps, drafts and their records, and the health check, with test-only
adapters and a fake agent runner (tests/unit/modules/workflow_service_test.py); the committed JSON Schema against
the example and the filled model (tests/unit/modules/workflow_schema_test.py, and make check for drift);
hz workflow through the CLI and REST heads, a receipted daemon run, one bad schedule leaving the other jobs and
matrix rows intact while check fails, compiled jobs in the capability matrix, and a hostile project hz.toml that
can't point discovery at its own contracts or raise the cap (tests/integration/workflow_test.py); contract names,
titles, error messages and doctor text shown as written, never as Rich markup and never with control characters
(tests/integration/cli_plain_text_test.py, tests/unit/heads/cli_render_test.py); run not reachable over REST
(tests/integration/rest_security_test.py); the daemon running compiled jobs and surviving a failing compiler
(tests/unit/modules/daemon_service_test.py); the kernel's handle_for serving only the compiler's read-only jobs
(tests/unit/kernel/bindings_test.py); which files expire, links below the data directory refused and never
followed, a file that can't be deleted named, pruning twice deleting nothing more
(tests/unit/modules/workflow_retention_test.py); the setting's bounds, prune and its dry run, retention 0, reads and
health never deleting, the doctor check and its grace day, and a compiled job's result without the draft's text
(tests/unit/modules/workflow_service_test.py); hz workflow prune through the CLI, not over REST, the daemon's
receipted workflow:prune, and a project hz.toml that can't shorten the retention
(tests/integration/workflow_test.py); windows back and ahead and their bounds, and contract inputs
(tests/unit/modules/workflow_contracts_test.py); the graph, its cycles (a 300-contract chain included), unmaintained
inputs, team contracts reading personal ones and Mermaid labels that can't break out of their quotes
(tests/unit/modules/workflow_graph_test.py); a contract reading the newest draft it depends on, a cycle that
doesn't run, and a calendar asked for the coming week (tests/unit/modules/workflow_service_test.py); hz workflow
graph as text, JSON and Mermaid (tests/integration/workflow_test.py). No test runs a real agent. Still planned:
golden tests for fingerprints and hz workflow check in a team repository's CI template.
More Information¶
- Amends ADR 0010 (contract format) and replaces the
ContractLetof ADR 0020 withCompiledJobs. - Research, schema and examples: docs/research/workflow-contracts.md; the example
contract the tests use:
tests/fixtures/workflows/weekly-digest.prose.md. - Status. Proposed. 0.2 builds the points marked 0.2 above in the
workflowmodule (director's decisions, 2026-10-24, and on review of the pull request, 2026-10-24); fingerprints, memo keys, input wakes, watches, team scope, approvals, migration and embedding are not built. - Interim lineage. A manual run's
.record.json(the packet, each input's hash and the agent's report) is interim lineage, not a receipt: it isn't hash-chained, and nothing lists or verifies it. Daemon runs get the chained receipt with the same lineage but without the draft's text (13); the record expires with its draft. When theLedgerprotocol moves into the kernel (0.3), manual runs write receipts too and the record folds into them. Until then a manual run has no receipt, andhz workflow runsays so. - Why PyYAML. It was already locked and audited (MIT) and has typed stubs; with a core-schema resolver and the limits above it reads frontmatter safely. ruamel.yaml stays the candidate for a comment-preserving writer.
- Open questions.
- Two canonicalization rules. §11 here and
canonical-v1in ADR 0022 must be one function; nothing depends on either yet. - People selectors.
ownerandmaintainerswait for ADR 0024's selector grammar. - Data file format for other proposals. Settled here for contracts (YAML). Recommendation for ADR 0023 and ADR 0024: data files people write are YAML, configuration stays TOML (ADR 0012).
- Manual contracts in the matrix. Only scheduled contracts are compiled jobs, so only they appear in
hz system capability;hz workflow showreports the requirements of the others. - Settled disagreements with the other proposals: the project directory is
.hz/workflows/(ADR 0016); the runner is configuration, not an enum (ADR 0029); inputs and publishes name capabilities and operations, not vendors (ADR 0028); tools: render packets pre-fetch inputs and the agent gets no hz tools (option a),hz.toolslater.