0054: Observability is whether each team promise is kept: telemetry in, a graph out¶
Context and Problem Statement¶
A team's work flows through artifacts its members' Heinzelmännchen keep: Costas's and Priya's rows of the tracker every
Wednesday, Mira's weekly report every Friday from those rows. ADR 0052
decided that the graph of this work emerges from what each Heinzelmännchen records, on top of the durable sessions of
ADR 0049, which are not built yet. The
maintainer decided (2026-10-24) what to build now, before them: observability means is each promise kept, for the
team layer only, like SLOs; a built-in module telemetry (after OpenTelemetry) where Heinzelmännchen log what they did
(hz report, an append-only log per Heinzelmännchen, synced into the team repository); a built-in module graph that
computes each promise's state from the team's contracts and those logs (hz graph, JSON Graph Format); and a graph
keeper that is just another Heinzelmännchen, shipped as a playbook. What exactly is a promise and its state, what may an
entry hold, and how do the logs reach the team?
Decision Drivers¶
- The team layer only. Sessions, lessons, personal amendments and edit sizes (ADR 0021's success test) are the personal layer and invisible to others by design; a telemetry entry is metadata, never content.
- Flow, never performance (ADR 0024): the team view counts promises kept per week and says where the flow waits ("weekly report waiting on 2 updates"); nothing aggregates anything per person.
- Deterministic and testable without a model or a network: plain code builds the graph, and tests assert on
hz graph --json, the shape the web app's graph view is built against. - Every module could be a plugin:
graphreads contracts and logs through kernel types, never throughworkflowortelemetry(the import-linter independence contract). - No central server (ADR 0027): the team repository carries the logs, as it carries contracts.
Considered Options¶
- Promises from team contracts' schedules, states from entries; a log file per Heinzelmännchen copied into the team
repository by
hz report sync; JGF v2 out; the keeper a playbook contract reading ateam-graphcapability (chosen) - Wait for ADR 0049's spine and
hz/state(action entries as spine events): the right end state, but nothing to observe for weeks; the entry shape and the graph are what carry over, the storage is what changes. - Push and commit automatically on every report: writes to a remote on each action, needs a lease or retries
when two members push at once, and turns a metadata log into a stream of commits; an explicit
synccomes first and the automatic step next. - An explicit due time in every contract (
hz.due): a new contract key, while the reader's schedule already says by when it needs its inputs; kept for when a leaf contract (nobody reads it) needs a deadline other than its next run. - The keeper as code in
graph(a job that computes nudges): the keeper reasons about what is blocked and how to say it, which is a contract's job; code would also make it the one Heinzelmännchen that isn't one.
Decision Outcome¶
Chosen option: promises from team contracts, states from telemetry, one log per Heinzelmännchen synced into the team repository, JGF v2 out, and the keeper a playbook, because it observes the team today with what exists (contracts, the team repository, the web head) and leaves storage to ADR 0049 without changing the shape anyone builds on.
- A promise. Each run of a scheduled, valid team contract (a slot of its
schedulein[graph] time_zone, else the zone the daemon uses, with the daemon's daylight saving rules) promises its artifact,contract:team/<name>#<output>, by a deadline: the next run of a team contract that reads it, or else its own next run. The kernel'sTeamPromises(provided byworkflow) handsgrapheach contract's promise: contract, title, version, schedule, the artifact it writes, the team artifacts it reads and its publish operations, never its body. Unscheduled contracts promise nothing. - Its state, one of five, from the entries its keeper reported between that slot and the next: kept (published, or
approved when the contract publishes nothing) is
on_timeby the deadline,lateafter it,fell_backwhen it also used a stale input; not kept islateonce the deadline passed, and before itofflinewhen the keeper was silent for[graph] offline_after(2 days),fell_backwhen it reported a stale input,waiting_for_approvalwhen it drafted and nobody approved, elseon_time. An entry counts for the run whose slot it follows, so an update made before its slot counts for the run before. Runs before a keeper's first entry aren't its promises. A Heinzelmännchen has one user and keeps one team contract: entries of another user under its id, or for another contract, are skipped and named inproblems. An artifact takes the worst state of its keepers; a team contract nobody's Heinzelmännchen reports for gets a keeper named after it, which shows asoffline. - Telemetry entries (
heinzel.TelemetryEntry,docs/schemas/telemetry-entry-1.json). Fixed fields:heinzel_id(a slug; it becomes ADR 0049's durable id),user(the roster handle),time(stored in UTC),verb,target,description(one line, at most 280 characters), andcontract(team:<name>, never a personal one) andpart(a part of the target, such as a tracker row, which becomes a node whoseparentis the artifact). Versions are plain tokens, checked for credentials like descriptions. The verbs are ADR 0052's:read(version,needed_by),drafted(contract_version),approved(by; the entry's time is when),published(version),fell_back(input),heartbeat(no target). A target is an https URL without user, password, credential-like query or fragment parameters, never a webhook (whose URL is a credential), an email address (only forpublished),contract:team/<name>#<output>orchat:<channel>; plain http and local paths are refused (a draft on your disk is yours). Descriptions with an email address, control characters or anything that looks like a token are refused; unknown fields are refused. Email addresses are allowed only as publish targets: the team needs to know a report went to its list, never what it said. - The logs. The package
telemetryis the modulereport:hz report <verb> <heinzel> <target> ...(one command per verb; CLI and MCP, never REST or the web head, since each writes) appends to<state dir>/telemetry/<heinzel_id>.jsonlunder an exclusive lock, refusing a symbolic link, a log of another user (one writer per log) or for a second contract, a time in the future or older than 30 days, and a log over 8 MiB.[report] usernames you, and no option overrides it (a projecthz.tomlcan't set it), so nothing you run files entries under a teammate's handle.hz report synccopies each log totelemetry/<user>/<heinzel_id>.jsonlin the team repository's checkout (OrgContext) only when the copy there is a prefix of it, so a team file has one writer, through a fresh temporary file it alone named, never into a link, a FIFO or anything but a regular file, and says what to commit and push. Readers validate every line again and skip, by file and line, a line filed under another user or Heinzelmännchen than its own. hz graph(modulegraph, one operation:hz graph, the MCP tool, andGET /api/graphon the web head; not on the unauthenticated REST head, since it reads team data) builds the graph afresh fromTeamPromisesand the kernel'sTelemetryLog(provided bytelemetry: the team's copies plus your own logs, yours winning). Filters:--artifact(a node id, its parts and what touches them),--heinzel(both together select both),--state(narrows that selection);--atshows it as of a time. A node'surlis an https place only, never an address.--jsonis a JSON Graph Format v2 document, profilehz-team, generated asdocs/schemas/team-graph-1.json:
{"graph": {"directed": true, "type": "hz-team", "label": "Team graph",
"metadata": {"as_of", "time_zone", "filters": {"artifact", "heinzel", "state"},
"weeks": [{"week": "2026-W43", "due", "on_time", "fell_back", "late"}],
"waiting": [{"artifact", "summary": "Weekly report waiting on 2 updates", "on": [ids]}],
"summary", "problems"},
"nodes": {"<id>": {"label", "metadata": {"kind": "artifact" | "heinzel", "owner", "state", "contract",
"contract_version", "deadline", "url", "parent", "last_seen"}}},
"edges": [{"source": "heinzel:<id>", "target": "<artifact id>", "relation": "reads" | "writes",
"metadata": {"state", "declared", "observed", "last_version", "needed_by"}}]}}
Heinzelmännchen are heinzel:<id>; artifacts are their contract output, or the target telemetry named. declared
edges come from contracts (the keeper of a contract writes its artifact and reads its inputs), observed ones from
entries; a write with a part goes to the part. Descriptions never reach the graph. weeks count promises whose
deadline fell in the week and are decided; waiting lists, for each scheduled contract that reads team artifacts,
the updates due by its next run and not made.
6. The graph keeper is a Heinzelmännchen. graph serves the capability team-graph (method snapshot, read
only) with its adapter hz-graph, the default binding, and declares a playbook (no setup step) whose template
graph:team-graph-keeper is a contract that reads team-graph.snapshot and drafts a note for its own user on where
the flow waits and what changed; it publishes nothing and addresses nobody else. The packet builder drops the graph's
email addresses like any input's. This amends ADR 0037's "the core knows no
playbook": the keeper is about hz's own graph, not a kind of team work.
7. Next steps. Automatic sync (each daemon tick commits and pushes the user's logs, or ADR 0049's hz/state branch
carries them as spine events); hz writing the entries for what it does itself (ADR 0052 §2), so report remains for
what it can't see; hz.due for leaf contracts; ADR 0052's aliases and conflicts.
Consequences¶
- Good: a manager sees "weekly report waiting on 2 updates" and the kept-on-time weeks without asking anyone, and the web app draws the same JSON the tests hold.
- Good: entries are small, strict and the same shape ADR 0049's spine will store; moving the storage changes neither
hz graphnor the web view. - Bad: until hz writes entries itself, a promise is only as observed as its Heinzelmännchen's reports; an unreported approval shows as waiting. Until sync is automatic, the team sees your entries when you push them.
- Bad: a leaf contract is due by its own next run, which is lenient;
hz.duewould tighten it. - Bad: four kernel types (
TelemetryEntry,TelemetryLog,TeamPromises, theTeamGraphprotocol) join the public API (ADR 0026).
Confirmation¶
tests/unit/kernel/telemetry_test.py (what an entry accepts and refuses); tests/unit/modules/telemetry_service_test.py
(appending under a lock, one writer per log, dates, links, sync's prefix rule, re-validating the team's copies);
tests/unit/modules/graph_build_test.py (every state, deadlines from readers and from the own next run, weeks, the
waiting list, observed and declared edges, cuts, zones); tests/unit/modules/workflow_promises_test.py (only valid
team contracts promise); tests/integration/graph_test.py (hz graph --json equals
tests/fixtures/graph/weekly-report.jgf.json, which equals web/src/test/fixtures/graph.jgf.json and validates against
the schema; filters; report, sync and the graph through the CLI; not on REST, on the web head; the keeper template
checks, binds hz-graph and dry-runs on the graph); make docs keeps both schemas in step with the models; the
import-linter independence contract lists graph and telemetry.
More Information¶
- Naming beside ADR 0052. The verbs are 0052's action names. The fixed fields keep the maintainer's names for this
step (
heinzel_id,time; 0052 writesheinzel,ts), anddrafted's andpublished's place is thetarget(0052'swhere); the command ishz report <verb>rather than 0052'shz heinzel report, since theheinzelmodule of ADR 0051 isn't built. When it is, the spine adopts this shape andhz heinzel reportbecomes the command, withhz reportkept as its alias for a release. - 0052's open question, what team members see of each other, is answered here only as far as the maintainer decided: states, deadlines and edges per Heinzelmännchen and artifact, never descriptions, costs or per-person counts.