0052: The graph emerges from recorded reads and writes¶
Context and Problem Statement¶
Today the graph of a team's work is drawn from declarations only: hz workflow graph takes its edges from explicit
contract:<namespace>/<name>#<output> inputs (ADR 0021 §7), and
ADR 0023 proposed an inventory compiled from declared YAML files with a typed entity schema, an
InventoryLet, a lock file and a single-writer rule, before any of it exists. Heinzelmännchen
(ADR 0049) read trackers, draft sections,
post messages and send mail as their user through vendor CLIs (ADR 0035),
which hz cannot observe from outside, and they read each other's artifacts without declaring it. The maintainer decided
(2026-10-24): the graph emerges from recorded reads and writes. First step: each Heinzelmännchen logs the actions it
wants to appear in the graph to an append-only action log, with a few fixed fields and a free-text description, and
decides itself what is worth logging; a graph agent reads all the logs and builds the graph; no upfront artifact schema,
and structured telemetry (OTel-like traces per turn, canonical artifact ids with versions) only once we see what the
graph needs. Two node kinds, artifacts and Heinzelmännchen, with edges "read from" and "wrote to", many-to-many;
declared and observed edges, both shown; no single-keeper rule; downstream wake-ups when an upstream draft is approved;
hz graph in the CLI and MCP with filters and JSON; a team graph with no central server, built by every hz from
metadata each daemon publishes; a report tool for what hz can't see. What is the log, who writes it, how does the graph
come out of it, and what do unapproved drafts do to the flow?
Decision Drivers¶
- Lineage (tenet 4) must be true, not asserted: an edge in the graph is a read or a write that happened, recorded at the moment it happened, by the one who did it.
- Hz can't observe vendor CLIs and coding agents (ADR 0035, ADR 0050 §6): what they did must be reported through hz, and the report must be cheap to make and impossible to forget for the actions hz itself performs.
- No upfront schema: three teams' graphs will show what an artifact id must be; a schema written now would be wrong and would make every Heinzelmännchen fill a form (against "creating one is trivial").
- No central server (ADR 0027, ADR 0020): metadata, never content, through the team repository; what a viewer can't open stays a name.
- No monitoring by construction (ADR 0024 §7): the graph and the flow show artifacts and Heinzelmännchen waiting, never a person's throughput.
- Self-management over orchestration: each Heinzelmännchen sees from the shared log who reads its artifact and by when, and talks only to its own user.
Considered Options¶
- An append-only action log per Heinzelmännchen with fixed fields and free text, written by hz for what it does and
by the Heinzelmännchen through a
reporttool for what hz can't see;hz graphbuilt deterministically from logs and contracts; a core graph Heinzelmännchen that proposes which targets are one artifact; team graph from metadata inhz/state(chosen) - ADR 0023 as written (declared YAML entities,
InventoryLet, a compiled lock file, single writer per artifact): a schema and a catalog before any observed edge exists; its four origins and merge rules are kept as vocabulary (declared, observed) and the rest waits until the logs show what is needed. - Structured telemetry first (an OTel trace per turn, spans for read, draft, approve and publish, canonical artifact ids with versions so "A wrote v5" links to "B read v5"): the right end state and explicitly the second step; chosen later, once the free-text logs say which ids and versions matter.
- Infer the graph from the session bodies (parse tool calls and model messages): bodies expire, hold content, and a handoff's reads through a vendor CLI are invisible in them; the log is written for the graph, at the time, by the actor.
- Heinzelmännchen message each other (an upstream one tells a downstream one its draft is late): a second conversation system beside the user's, and an orchestrating Heinzelmännchen next. Deferred, probably never: the shared log and the contracts already tell each one who is waiting.
- Approvals block hard (downstream never runs on an unapproved upstream, ADR 0038 §6): correct for publishing, but a report due Friday 17:00 can't wait for a tracker row nobody approved; the fallback below proceeds with the last approved version and says so.
Decision Outcome¶
Chosen option (proposed): every Heinzelmännchen keeps an append-only action log of the reads, drafts, approvals,
publications, fallbacks and heartbeats it wants in the graph; hz writes the entries for what it does and offers a
report tool and operation for what it can't see; hz graph derives two node kinds and two edge kinds from the logs
and the contracts, declared and observed side by side; a team's graph is built by each hz from the metadata every
daemon publishes to the team repository. Checkable statements:
- The action log. Entries are events of kind
actionin the Heinzelmännchen's spine (ADR 0049 §3: append-only, chained, kept for its life) and rows of the index'sactionstable. Fixed fields:heinzel(the id),user(the roster handle, ADR 0024 §4),ts,verb,target(a URL, an address, a channel, or an asset ref as the contract names it; a string, not a schema) anddescription(free text, one or two sentences, what the Heinzelmännchen wants a reader of the graph to know). Verb-specific fields, the minimum the graph and self-management need (the maintainer's list):
| Verb | Fields | Written by |
|---|---|---|
read |
version (as the source reports it: a revision, a hash, an as_of), needed_by (from the reader's contract: its next deadline) |
hz, for every input a turn gathers; the Heinzelmännchen, for reads through tools or a handoff |
drafted |
where (the place the draft was put), contract_version, draft (hash) |
hz, from draft.placed |
approved |
by (handle), at, share_changed, output_fp (the approved text's material fingerprint, amended 2026-10-24, 7); rejected likewise |
hz, from approval.observed |
published |
where (the document, the address, the channel) |
hz, from a publish operation; the Heinzelmännchen, when a handoff sent something through a vendor CLI |
fell_back |
input, used (the stale version), deadline |
hz, from the flow rule (7) |
heartbeat |
none | the daemon, once per tick per Heinzelmännchen that is not retired, coalesced to one entry per day in the spine (the index keeps the last) |
aliased |
targets (two or more), because |
the graph Heinzelmännchen (5) |
Entries never hold draft text, fetched content, names or addresses of people other than the user's own handle, or
per-person counts; the lint of ADR 0049 §10 applies to them.
2. Who writes what. Hz writes the entries for effects it performs itself (in the act step of a turn, ADR 0049 §4)
and for every input it gathered, so the common case costs the Heinzelmännchen nothing and can't be forgotten. The
Heinzelmännchen decides what else is worth logging and writes it through the report tool
(ADR 0050 §3: the one tool that writes, and it writes
only to its own log): report(verb, target, description, **fields). The same thing is the operation hz heinzel
report on the CLI and the MCP head (heinzel_report), so a coding agent in a handoff, which acts through vendor
CLIs hz can't observe, reports what it read and where it published; the handoff's instructions say so, and its working
directory names the Heinzelmännchen the entries belong to. A report from a handoff is attributed to the turn and
marked via: handoff. The free text is data: clean_text, redacted, cut at 1,000 characters, never instructions to
anything.
3. Two node kinds, two edge kinds. hz graph derives, deterministically and without a model: a node per
Heinzelmännchen (id, name, state) and a node per distinct target after aliases (5); an edge "read from" per
read entry (Heinzelmännchen → artifact, with version and needed_by) and "wrote to" per drafted or published
entry (Heinzelmännchen → artifact, with contract_version, pending or approved). Many Heinzelmännchen may write to
one artifact and one may write to many: there is no single-keeper rule; where two have pending drafts in one
place, the graph marks the artifact conflicting and both users see it in hz drafts and on the page ("another draft
is pending here, by weekly-report of @mira"); nothing is merged silently (ADR 0038
§7 keeps "hz never fights an edit"). Declared edges come from contracts (inputs and publishes, as ADR 0021 §7
draws them) and are shown as such; observed edges come from the log; both are shown, and a declared edge nobody
has exercised, or an observed one no contract declares, is visible as what it is, never an error. Cycles are a
finding of the declared graph as today (HZC030).
4. hz graph. A top-level built-in module graph with one operation, so hz graph, the MCP tool graph and
GET /api/graph for the web app (CLI, MCP, web; a read). Filters: --artifact, --heinzel, --user, --since,
--edge read|wrote, --pending (artifacts with drafts awaiting approval), --declared-only, --observed-only;
formats: text, --json (nodes, edges with their fields, conflicts, aliases applied) and --mermaid; the JSON is the
contract tests assert on. hz heinzel graph (ADR 0051) keeps drawing the declared graph of contracts and is hz
graph --declared-only with the old name, deprecated with the rest. The web app's graph page reads the same JSON.
5. The graph Heinzelmännchen. Canonical artifact ids are not designed now; instead one core Heinzelmännchen per
user, graph, with a contract hz ships ("keep my graph's artifacts well named and merged"; schedule daily; reads the
user's action logs and the team's published metadata; no inputs from the world), reads the logs and proposes
aliased entries: these targets (a Google Doc URL with and without a tab fragment, "the weekly report", the asset
ref the playbook uses) are one artifact, named like this. It writes them to its own log through report; hz graph
applies them; the user sees them in its page and can reject one (alias.rejected, never re-proposed). It never
writes to the world and has no publishes. When the aliases show which identifiers matter, the second step
(structured ids with versions, a trace per turn) is written as its own record and the free text stays beside it.
6. A team graph, no central server. For a team Heinzelmännchen, action entries are the receipt-grade events of
ADR 0049 §10 and go to the team repository's hz/state branch with them (metadata only, the same lint). Every hz
builds the team graph from hz/state plus its own logs; nothing is computed centrally. An artifact a viewer can't
open (its own adapter can't read the target as them) shows as the name the log gave it, nothing more; a
Heinzelmännchen of another user shows as its name and user handle. Open (the maintainer's): what team members see
of each other in it; this is a works-council question (ADR 0024), and until it is answered
the team graph shows a user's Heinzelmännchen to others as names, states and edges, never descriptions, costs, counts
or timings.
7. Downstream wakes. When an upstream draft is approved (approved entry), every Heinzelmännchen whose log has a
read of that target wakes with cause source-changed on its next tick (ADR 0049 §4): the approval is the input
that moved. Declared readers and observed readers both count.
- Amended 2026-10-24 (ADR 0049
statement 20, OpenProse Reactor's early cutoff,
spec): readers wake only when the
approved text's material fingerprint moved: approval.observed and the approved entry (1) record
output_fp (the canonical fingerprint of the approved text, per facet when the output has facets, ADR 0021 §8),
and an approval whose output_fp equals the previous approval's wakes nobody, so a re-approval of identical text
or a change in a facet nobody reads costs no turn downstream. The cutoff works from the logs a machine has; for
readers that learn of approvals only through ADR 0054's
telemetry it waits until that record's approved entry carries output_fp, and until then they wake as before.
For readers of published truth, only the approved text propagates, never a pending draft (the turn body is the
workspace, the approved text the truth); readers with stage: draft (ADR 0021 §7) still see drafts. A turn that
ends skipped or failed writes no drafted entry (its disposition is in the spine's turn.finished, or
turn.skipped for a slot where nothing moved, ADR 0049 statement 15), so a postcondition that wasn't met leaves
the previous truth standing and wakes nobody.
To confirm: unapproved drafts block the flow (the maintainer's open design, 2026-10-24; proposed here, decided when the first team runs it):
- Deadlines flow upstream. A contract's deadline (its
schedule, or an explicitby) gives everyreadentry itsneeded_by; an upstream Heinzelmännchen reads the logs of its artifact's readers and knows by when its draft must be approved: the report due Friday 17:00 means the tracker rows must be approved by Friday noon (the reader's lead time, a contract keyleaddefaulting to the reader's turn timeout plus one tick). It derives this from the shared log and the contracts, not from a message. - Nudges, repeated as the deadline nears, each saying who waits and why. "Your tracker row is waiting for approval:
weekly-report(Priya) reads it by Friday 12:00." This amends ADR 0038 §5 ("one nudge, once more when stale"): the schedule is one at placing, one at half the lead time and one at the deadline, through the user's notifications (ADR 0043), each Heinzelmännchen nagging only its own user. - Graceful fallback. At the deadline the downstream Heinzelmännchen proceeds with the last approved version of the
input, marks the draft stale on that input (in the draft tag's line and the outcome's report) and logs
fell_back. This amends ADR 0038 §6 ("downstream waits for approval") for inputs; publishing to an audience still waits. - Escalation only where the team's contract says so. A contract may name
escalate: [manager, delegate](ADR 0024 selectors); without it nobody but the user is told. An escalation is skipped when the user's calendar shows them away, and then the fallback is the only consequence. - Deferred, probably not needed: Heinzelmännchen messaging each other; an orchestrating manager Heinzelmännchen. An offline daemon shows as a stale heartbeat; its downstream sees staleness and falls back.
- Shown as flow, never as performance. "Weekly report: waiting on 2 approvals" on the page and in
hz drafts --flow; never "Theo approves late". No operation aggregates approvals, delays or fallbacks per person (ADR 0024 §7).
Consequences¶
- Good: the graph is true by construction (every edge is a logged read or write) and costs nothing to declare; the catalog of ADR 0023 is replaced by what happened.
- Good: no schema to get wrong now; the aliases the graph Heinzelmännchen proposes are the evidence for the schema later.
- Good: a team sees who waits on what without a server, and each Heinzelmännchen manages itself from the same log.
- Bad: free text and loosely named targets make a noisy graph at first; the graph Heinzelmännchen and
--pendingkeep it useful while the second step is designed. - Bad: a
reporttool is a write a model makes; it writes only to its own log, is lint-checked for content, and a wrong report is a wrong edge the user can see, never an effect in the world. - Bad:
hz/statecarries more metadata (reads with targets and deadlines), which sharpens the works-council question; the default (names, states and edges only) is conservative until the maintainer decides. - Bad: the fallback means a draft can be built on a stale input; it is marked, logged and nudged, which is the trade the maintainer proposed over a report that doesn't appear.
Confirmation¶
Not yet: proposed. When implemented:
tests/unit/modules/heinzel_actions_test.py: hz writesread,drafted,approved,published,fell_backand the dailyheartbeatfrom the turn steps;reportentries through the tool and the operation, attributed to the turn,via: handofffrom a handoff; the lint refuses text, addresses and counts.tests/unit/modules/graph_test.py: nodes and edges from a fixture of logs and contracts; declared and observed shown apart; many writers to one artifact;conflictingon two pending drafts; aliases applied and a rejected one ignored; every filter and--jsonstable (golden file).tests/integration/graph_test.py:hz graphon the CLI and MCP with filters; the web endpoint behind the token; nothing on the unauthenticated REST head.tests/unit/modules/heinzel_flow_test.py(once the open design is decided):needed_byfrom the reader's deadline, the nudge schedule, the fallback at the deadline withfell_backlogged and the draft marked stale, escalation only when declared and skipped when away, no per-person aggregation (tests/unit/repository_test.py).tests/e2e/priya_test.py(ADR 0049) gains Theo'smy-rowHeinzelmännchen upstream ofweekly-report: the graph shows the edge, the Friday-noonneeded_by, and the fallback when Theo doesn't approve.
More Information¶
- Decided by the maintainer, 2026-10-24: the action log as the first step, its minimum fields, the graph agent, two
node and two edge kinds, declared and observed edges, no single-keeper rule, downstream wakes on approval,
hz graphwith filters and JSON, a team graph from published metadata without a server, the report tool; the flow design is the maintainer's proposal to confirm. - Amends ADR 0021 §7 (edges come from explicit inputs and from observed reads and
writes); ADR 0023 (no declared entity schema, lock file or
InventoryLetfor now; its single writer per artifact rule is dropped; its vocabulary of declared and observed origins stays;hz inventoryas a module waits for the second step); ADR 0038 §5 (nudges repeat as the deadline nears) and §6 (downstream falls back on inputs at the deadline), proposed with the flow design; ADR 0049 §10 (the receipt-grade events are action entries) and §3 (thereporttool is the one that writes, to the log). - Questions for the maintainer (the flow design): (1) Is the lead time a contract key (
lead) or derived from the reader's timeout and tick (recommendation: derived, overridable)? (2) May a downstream Heinzelmännchen fall back more than once in a row on the same input, or does the second time pause it and tell its user (recommendation: pause after two)? (3) Who may name an escalation target: the team's contract only, or the user's own too (recommendation: the team's contract, by pull request)? (4) What does the team graph show of others by default (recommendation: names, states and edges, no descriptions or timings, until a works council has been asked)? - Steps 11 to 13 of ADR 0049's staged plan build this record.