0081: State repositories: one writer per branch, append-only segments, the daemon pushes¶
Context and Problem Statement¶
A team wants to know who actually uses hz: who onboarded and who is active day by day. hz has no server and sends
nothing home (ADR 0027); git hosts the team already trusts are the only
place to share state. ADR 0054 §4 copies telemetry
logs into the team checkout by hand (hz report sync) and names automatic sync as the next step;
ADR 0049 §10 puts machine state on an
orphan branch of the team repository. Pushing to a repository's main from a daemon fights its branch protection
and races every other writer. How does hz share machine state through git without conflicts, without a server, and
without turning usage into a measure of people?
Decision Drivers¶
- Conflict-free by construction: no merge, rebase or force-push on any write.
- Offline first: the local files are the queue; nothing is lost while the machine sleeps or is offline.
- Works with any git remote (a bare repository on disk too); credentials stay git's.
- Flow, never performance (ADR 0024 §7): nothing finer than a day, aggregate by default, a person named only with their consent.
- Small: no new runtime, dependency or hosted service.
Considered Options¶
- One orphan writer branch per user and machine, append-only segments, pushed fast-forward by the daemon (chosen)
- Per-writer files on
main, ADR 0054 §4 automated: every push races the others' commits and needs a fetch and rebase, and a daemon would need push rights pastmain's protection, or a pull request every 15 minutes. - GitHub's REST API as an append store (Contents API with the blob SHA as compare-and-swap): no offline mode, a token in the process, and a team of daemons trips the secondary rate limit on content creation.
- A versioned database (Dolt, Irmin, CRDT documents in git): a new runtime and hosting outside the git host, and opaque history that can't be deleted branch by branch.
Decision Outcome¶
Chosen option: one orphan writer branch per user and machine, append-only segments, pushed fast-forward by the daemon, because one writer per ref can't conflict, the local streams already are the queue, and a person's data is one branch they can delete. This slice builds the personal sink; the team sink follows behind its own consent.
- Writer branches. A state repository holds a reviewed
mainthat hz never pushes to, and brancheshz/<user>/<machine>, each an orphan with exactly one writer: hz on that machine for that user.<user>is the roster handle in[report] user;<machine>is eight random characters made once in the state directory, never the host name. The branch holdshz-state.json(v,user,machine,hz,streams) and one segment per stream and month,<stream>/<YYYY-MM>.jsonl, laid out exactly as<state dir>/state/is locally. - Streams are declared by modules, never discovered, and each line is strict metadata validated by its model on
write and again before every push.
system.statedeclares two: presence, one line per local day on which hz was used on this machine ({"v":1,"date":"2026-10-25","hz":"0.4.0","kind":"active"}), and adoption, a line when hz is first seen (installed) and one per new version (upgraded), with the OS family. Lines carry no user or machine (the branch names both), no time of day, count, command, path, host, address or cost. "Used" is a person: authenticated heads note it through the kernel'sPresencebefore they run an operation (CLI, stdio MCP, private API and web operation streams), except for the daemon's own module, whose ticks the OS starts. Ambient API calls and notice-stream reconnects do not count. Presence never fails or slows an operation. Telemetry (ADR 0054's entries) becomes a third stream when the wake loop writes it, in a later slice. - The push. The daemon job
system.state:pushruns every 15 minutes and waits up to 90 seconds at random first, so a team's machines don't push together;hz system state pushpushes now. It fetches only its own branch into a clone below the data directory, checks that every segment there is a prefix of the local segment, copies the local bytes, commits once (state: <n> new line(s) in <k> segment(s), author<handle> <<handle>@hz.invalid>, hooks and signing off) and pushesrefs/heads/hz/<user>/<machine>without force. Nothing changed, nothing is pushed. Every git command goes through the kernel's hardened runner (GitRunner, moved from the team module: no prompts, no submodules, noext::, allowlisted environment, 300 s timeout); credentials are git's own and hz never reads, stores or prints one; errors are kept with anyuser:password@removed. - Failures and refusals. A failed push is recorded and backs off, 15 minutes doubling to a day; doctor reports a
repository that took no push for two days. A branch with commits this machine didn't make, or a segment with
lines the local one lacks, stops the sink (HZS001): hz never merges, rebases or overwrites it.
hz system state resetmoves the clone aside (kept as evidence) and starts again from the local streams, which stay the truth. - The personal sink is a private repository the user names,
[system.state] personal(no default; nothing leaves the machine until it is set). hz holds no repository name of anyone. A projecthz.tomlcan't set it. - The team sink (next slices) exists only behind explicit consent: off until the person says yes, then aggregate,
and their handle may appear in the team's views only after a second, separate yes. Team state goes to writer
branches of the team repository by default, beside its
main, and a team can name another repository instead. A team copy is the stream filtered by the stream's own pure rule, and readers check the prefix rule on that projection.hz team adoptionreads the team's branches into aggregates: weekly active people of the roster, onboarding, versions; no view only a manager has. -
This amends ADR 0054 §7: its automatic sync is this push;
hz report syncstays as it is until the telemetry stream and the team sink replace it. -
Inactive jobs amend ADR 0020. A job may return typed
JobInactive(reason=...)when unconfigured or backing off. The tick reportsskippedwithout a receipt or per-job log entry. Missed slots and idempotency skips retain their existing receipts. This prevents 96 empty state receipts daily without hiding a failed push. - Private state amends ADR 0049 §10. This slice keeps session bodies local; only the explicit metadata streams
enter the user's personal sink.
status,pushandresetrequire authenticated private API, local CLI or stdio MCP identity; ambient API and HTTP MCP cannot read them. Thepersonalconfiguration field declaresx-privatemetadata: generic config reads/reporting require private read permission, writes require private write permission. The public doctor retains health severity while redacting personal state details. - Local ownership and recovery. URL plus writer branch keys the clone. Private receipts in
.git/hz-owned.jsonrecord each locally created commit before pushing. A fetched tree is validated through Git objects without checkout; a remote commit needs a local receipt and ancestry from the last confirmed push. Foreign fast-forwards, rewinds, deletions, links, unknown paths and malformed segments stop the sink. Commit author and committer dates are midnight UTC on the local date. One stable inter-process lock covers push and reset; stream locks are acquired inside it. A crash before recording ownership fails closed; a crash after server acceptance is reconciled by the next fetch. Pending counts read the confirmed commit, never an unpushed worktree.
Consequences¶
- Good: no write can conflict; offline machines push when they wake; deleting a person's data is deleting their
branches, and
mainholds only what people reviewed. - Good: any git remote works, a bare repository on disk too, so the acceptance tests run real git without a network.
- Bad: GitHub removes a deleted branch's objects only eventually, and a fork or clone keeps them; revocation must say so.
- Bad: a reader fetches one branch per machine; for large teams that is many refs, and aggregates on
main(a later slice) become the read path for old ranges. - Bad: the kernel's public API grows by
Presenceand the git runner (GitRunner,CommandFailed,RunGitCommand,check_repository_url,git_command,GIT_HARDENING) (ADR 0026).
Confirmation¶
tests/unit/modules/state_streams_test.py (one presence line a day, installed then upgraded once per version, links
refused, line validation); tests/unit/modules/state_service_test.py (no repository or handle is a skipped run,
backoff, jitter, the daemon module never counts, scrubbed errors, status and doctor); tests/integration/state_test.py
(real git against a local bare repository: the first push creates the orphan branch, the second is a fast-forward or
nothing, a foreign commit stops the sink, reset recovers after the branch is deleted, the daemon tick runs the job,
a CLI command notes presence and the daemon's tick doesn't); tests/unit/kernel/presence_test.py; tests/unit/public_api_test.py; the import-linter
contracts list system.state and the kernel's _git and _presence.
More Information¶
- Research and the full design, including the read path (shallow fetch of
hz/*, a derived SQLite index) and the slices after this one: telemetry as a stream written by the wake loop;hz team adoptionand its views; the team sink by consent with revocation that deletes the person's branches; integrity (a hash chain per segment, signed commits); aggregates onmain. - Prior art: git-bug (operations under per-entity refs, readers fold), git-annex (per-repository branches), and git scraping (git history as a time index, one writer per file).