0060 — The heartbeat tells the truth, and passes the gate by the gate's own rule¶
Status: proposed · Date: 2026-09-24 · Cites: sub-doctrine 10.i, drafted after this record merged (#1121) and awaiting the operator's ratifying merge; the CLAUDE.md non-negotiables "Unattended authority is bounded by a gate, never by judgement" (12.d), "Everything-as-code" (12.c), "Toil that can be fully automated is" (12.e), "Status is evidence-bounded" (10.f) and "Code lives in classes" (9.b); doctrine 8.a · Related: ADR-0016, ADR-0018, ADR-0042, ADR-0046, ADR-0047, ADR-0050 · Evidence: develop at e89818ce, read 2026-09-24; pre-commit 4.6.2 commands/hook_impl.py::_pre_push_ns; git's pre-push stdin as observed against a local bare remote
Owes:
- the advertised ADR count in
CLAUDE.md,AGENTS.md,GEMINI.md,README.mdanddocs/index.md, and a nav entry inproperdocs.yml(done in the change that carries this); - a sub-doctrine. Two rules here bind future decisions and are conduct, so under ADR-0020
they belong in the canon. This record applies them; it does not ratify them. #1121 merged
this record without them; they are drafted as sub-doctrine 10.i — the signal is read
before it is sent in
src/vibey_tools/gh/docs/doctrines.mdby the pull request that follows it, and become law only when the operator's merge of that pull request ratifies them (Article II.3).
Context¶
The PR-review gate schedules the sovereign (local Ollama) review only while
refs/vibey-gh/sovereign-heartbeat is fresh ([pr_automation.fallback]
heartbeat_max_age_minutes, default 15); otherwise it falls back to "needs a human". The ref
was published by vibey-local-authority, a LaunchAgent in one operator's home directory that
ran vibey-gh sovereign --beat on a timer. Two things were wrong with it.
It skipped the gate. beat() pushed with git push --force --no-verify, and its docstring
called --no-verify load-bearing: the pre-push stage runs the suite, a coverage pass and a
network audit, measured at more than two minutes against 1.2 seconds skipped, on a push that
carries an empty tree with no parents. The measurement was right and the remedy was not. 12.d
forbids --no-verify and "any means whose purpose is to make a check stop applying", with no
exceptions, and a caller-side skip applies to whatever the push happens to carry, not only to
the empty commit it was written for.
It was not honest. The agent decided when to beat from its supervisor having a live
process. While runner registration was failing, that process lived for about thirty seconds
of every restart cycle, so a heartbeat went out at 2026-09-22T01:38Z with no runner able to
take a job. A fresh heartbeat with no runner makes the sovereign job queue forever, and the
required gate needs it.
And it was hand-made: nothing in the tree declared it, could restore it, or could say it had stopped. At the operator's request it has been retired.
Decision¶
1. The gate decides its own scope¶
The pre-push stage judges code on its way off the machine, so it now recognises, by its own
rule, a push that carries none: every ref being pushed is outside refs/heads/ and
refs/tags/, and every commit it sends is the empty tree with no parents. Such a push ends
the hook before the heavy stage; every other push runs the gate in full.
- The rule lives in one class,
vibey_gh.push_scope.PushScope, withinterfaces/push_scope_interface.pybeside it, and one command,vibey-gh push-scope. - It reads git's pre-push stdin, all of it, once, in the hook
vibey-gh installrenders (templates/githooks/pre-push, and its rendered copy.githooks/pre-push), before anything else can consume it, and hands every line on to the chainedpre-push.local. It is not a wrapper around each hook in.pre-commit-config.yaml, and that is deliberate: pre-commit reads only the first line of pre-push stdin and exports only that ref (PRE_COMMIT_REMOTE_BRANCH,PRE_COMMIT_FROM_REF/TO_REF), so a per-hook check reading those variables would passgit push origin <heartbeat>:refs/x main, whose first ref is a heartbeat and whose second is a branch. One check at the point where the list is complete, in front of every heavy hook, is the single scope check they all sit behind. - It asks git about the objects:
cat-file -tmust saycommit(so an annotated tag is never peeled into the commit it points at), the commit must have noparent, and its tree must be the empty tree asgit hash-object -t tree /dev/nullcomputes it in this repository, so a SHA-256 repository is judged in its own format. - It fails closed. A branch, a tag, a non-empty tree, a commit with a parent, a deletion,
an object that is not a commit, an object git cannot read, a line git would never write, or
no refs at all — each carries code. The hook ends early only on the exact token
carries-no-codeon stdout; an oldervibey-ghwithout the command, a crash, or a stub that exits 0 for everything prints no token, and the full gate runs. - There is no flag, variable or file that asks for the exemption. This is the gate applying
its own rule, not a caller skipping it: a heartbeat that carried one file, or rode beside a
branch, is judged in full. Each refused case is a test (
test/test_push_scope.py, and end to end through a real push intest/test_push_scope_hook.py).
2. beat() drops --no-verify, and --force becomes a compare-and-swap¶
The heartbeat pushes through the checkout's own gate. The bare --force is also gone, because
12.d allows no unattended act that cannot be undone and a bare force replaces whatever the ref
holds, including a value this machine never saw. beat() reads the ref's current value with
ls-remote, fetches it if absent locally, confirms with PushScope.is_empty_root that it is
itself a heartbeat, and pushes with --force-with-lease=<ref>:<that value> (an empty lease
when the ref does not exist). Only that exact heartbeat is ever replaced; a concurrent write,
or anything on the ref that is not a heartbeat, is refused and left as it was. Replacing a
commit that carries nothing, and only the one that was read, is not an irreversible act.
3. An honest heartbeat¶
beat() now takes a LaneReadinessInterface and publishes only when the lane can serve.
SovereignLaneReadiness (vibey_gh/sovereign_lane.py) reads, never assumes, two things:
- the runner: a self-hosted runner carrying
[pr_automation.fallback] runner_labelis registered with the repository[runners]declares and GitHub reports itonline(busy counts: it takes the next job when it finishes). Read fromrepos/<owner>/<name>/actions/runnerswith the runner's own file-based login ([runners] gh_config_dirasGH_CONFIG_DIR, every ambient token stripped) — the one credential on the machine with the Administration permission that API needs — throughSovereignRunner.registered_runners, which never surfaces gh's output; - the model: the endpoint
[pr_automation.fallback] base_urlanswers and holdsmodel, read through the fit'sOllamaModelSampler.
Anything else — no runner, one offline, a listing that could not be read, a model endpoint
that did not answer — withholds the heartbeat with the reason and pushes nothing, so the ref
goes stale on its own and the gate falls back honestly. --beat exits non-zero and, with
--record <file>, writes what it did (published or withheld, and why) for heartbeat status.
The workflow side is unchanged: it still cannot ask GitHub (GITHUB_TOKEN cannot hold
administration: read) and still reads only the ref's age. What changed is that the age now
means what it says.
4. The timer is declared¶
vibey-gh heartbeat install | status | uninstall, and vibey-gh runner install / runner
uninstall do the same for the heartbeat alongside the runner. From [runners] and
[pr_automation.fallback] it renders a launchd agent on macOS or a systemd user service
and timer on Linux (Ubuntu LTS is first-class, #1116); [runners] heartbeat_scheduler picks
one explicitly. It runs python -m vibey_gh.cli sovereign --beat --record <file> from the
repository's main checkout every heartbeat_interval_minutes — 0 takes half of
heartbeat_max_age_minutes, and anything over half is refused, so one missed beat never
stales the lane.
Render refuses an interpreter, a vibey_gh, or a log directory under a temporary directory
(a reboot empties it: this machine lost its temporary directory the day this was written; ADR-0057) or inside a
git work tree (a lane's worktree is deleted when the lane ends; a checkout's virtualenv changes
under every sync), and a checkout that is a linked worktree. The interpreter is the one running
the install unless [runners] heartbeat_python names another; the vibey_gh location is asked
of that interpreter exactly as the timer will run it. Nothing is loaded unless --load is
given; uninstall is a dry run until --apply, and moves units into
<install_dir>/retired-units/ rather than deleting them. status reports the units present
and current (judged against the interpreter the installed unit runs, not the one running
status), whether the timer is loaded, and the last beat's age and result from its record;
it exits non-zero unless the last beat was published inside the trust window.
Consequences¶
- The heartbeat reaches the remote through the same gate as everything else and in seconds; the gate's exemption is a rule anyone can read in one sentence and check against the objects.
- A fresh heartbeat now means a runner was online and the model answered when it was written. A heartbeat that stops is still indistinguishable from a machine that stopped, which is still the point.
vibey-local-authorityis retired. Its successor is installed byvibey-gh heartbeat install --loadfrom the main checkout, with avibey-ghinstalled outside any checkout (for exampleuv tool install vibey).vibey-gh local-authority, the command that pushes green local branches when the paid lane is capped, is unrelated and unchanged.- The hand-written agent could be restored by nobody; the declared timer can be restored by anyone with a clone.
Alternatives rejected¶
- Keep
--no-verify, with a written exception. 12.d admits none, and the exemption would cover whatever the push carried. - Push the heartbeat from a separate bare repository with no hooks. A means whose purpose is to make the check stop applying, by another name.
- Wrap each heavy hook with a check on
PRE_COMMIT_*. Passes a heartbeat riding beside a branch, as §1 shows. - An environment variable the heartbeat sets to say "nothing to judge". A caller's flag.
- Keep the bare
--force. It replaces a value it never read.