Skip to content

feat(gr2): Prototype 1 propagation daemon on one declared managed replica - #894

Merged
laynepenney merged 4 commits into
devfrom
feat/gr2-propagation-prototype-1
Aug 20, 2026
Merged

feat(gr2): Prototype 1 propagation daemon on one declared managed replica#894
laynepenney merged 4 commits into
devfrom
feat/gr2-propagation-prototype-1

Conversation

@laynepenney

Copy link
Copy Markdown
Member

What

Prototype 1 of the propagation daemon: the Prototype 0 state machine (#889) run on a loop against ONE destination that a declaration names as a managed replica of one branch of one source. This is the step before the daemon is allowed near a real clone, and it is built so the declaration cannot give it more than that.

gr2/prototypes/propagation_daemon.py:

  • Declaration names exactly one managed replica (source_url, branch, destination_id, destination_path, state_dir, outbox_root, a coordinate, an interval). kind may only be replica; an authoring kind, an unknown git environment, a non-positive interval, or a missing field is refused before any git call. The policy is fixed to downward-only, the one direction a managed replica can be the destination of.
  • ensure_replica clones the declared branch single-branch when the path is absent; when the path exists it must be a git checkout whose origin is the declared source and whose current branch is the declared branch, otherwise it is refused before the machine ever reads it (a daemon must not fast-forward a clone that merely sits at the path). A non-git directory is refused too.
  • One tick: observe the source with git ls-remote; if the cursor already names the source revision there is no operation, no receipt, and nothing is written. Otherwise drive the machine once and take its receipt, whatever its state — a refusal is a receipt too — write it as its own JSON file with per-state latency derived from the receipt's own transition timestamps (not a stopwatch around the call), and emit one propagation.receipt event on the gr2 outbox carrying the one-line summary. The daemon writes that line to the outbox and to stdout and knows no channel, so the prototype depends on nothing outside gr2.
  • Git environment is a declared choice: isolated from the host configuration by default (the Prototype 0 default); "git_env": "inherit" for a real private remote that needs the host's credential helper. The daemon never creates a commit, so inheriting does not invoke signing. In both modes GIT_TERMINAL_PROMPT=0 is set: a daemon never answers a prompt, so a missing credential fails the tick instead of hanging the loop on a tty.
  • A tick that fails on the environment is printed and counted, and the loop goes on. run_loop catches SourceUnobservable (the machine's new name for a failed or empty ls-remote, raised before any state is touched), DestinationUnreadable (the machine's wrapper for a failed destination read, raised at observe, plan, or verify, each a point it replays from), CalledProcessError, and OSError; the catch list is derived from the machine's raise sites reachable from a tick and says so in a comment. It prints one propagation tick-failed line, increments LoopStats.failures, and continues; the next tick replays whatever the machine left pending, which is the machine's own kill-and-replay contract. What propagates, by design and by name: JournalInconsistent and LookupError from the machine (a journal that cannot account for its own cursor is corrupted sink state), DeclarationMismatch from ensure_replica at startup, and any defect in the daemon module — and that claim is witnessed from the other side (a forged cursor leaves run_loop by name with nothing written; LookupError, a bare RuntimeError, and ValueError from a tick are not swallowed).
  • The not-new path asks the machine too. The tick observes the source once and hands that observation to Propagator.run on both paths (new: the operation; not new: the machine's cursor check, which raises JournalInconsistent for a cursor the journal never acknowledged). An earlier version of the tick returned "current; not an operation" without asking, so the machine's corrupted-sink check never ran on the daemon path and would have hidden corrupted sink state on every tick forever; the witness for the escape claim found it.
  • run_loop / CLI: one printed line per tick, --once, --interval override; stdout is resolved at call time so a redirecting caller gets the lines.

gr2/prototypes/propagation_state_machine.py (Prototype 0) gets two small changes the first dogfood run demanded: Propagator now distinguishes git_env=None (the isolated default) from an explicit {} (inherit the host environment), where it previously collapsed the falsy {} into the default, so the daemon's inherit mode silently lost the host's credential helper and ls-remote prompted for a username; and a failed or empty ls-remote raises SourceUnobservable (a RuntimeError subclass, so any caller of the bare class still catches it) instead of a bare RuntimeError, so a looping caller can recognise it by name. A third small change names the machine's one deliberate escape: a cursor at a revision the journal never acknowledged raises JournalInconsistent (also a RuntimeError subclass) instead of a bare RuntimeError, so an escape that is meant is distinguishable from one that is not at any catch site. A fourth: Propagator.run takes an optional observation, so a caller that already observed the source this tick can hand it over instead of asking the source twice; the cursor check runs on it all the same, because "nothing new" is exactly the answer that would hide a corrupted sink state, whoever observed.

gr2/python_cli/events.py gains EventType.PROPAGATION_RECEIPT; gr2/docs/HOOK-EVENT-CONTRACT.md documents it in the 3.2 taxonomy and the 7.2 enum listing, with receipt_path declared as an explicit exception to the relative-path rule (the receipt store is daemon state under the declared state_dir, not a workspace file); the exhaustive EventType count test moves from 35 to 36.

gr2/tests/test_propagation_daemon.py builds a bare source remote, an authoring clone, and a declared replica path, and proves, each as its own witness:

  • the declaration builds a downward apply coordinate for the declared replica; each required field, a missing or incomplete coordinate, any kind but replica (with subprocess.run patched to fail the test if git were invoked), an unknown git_env, and a non-positive interval are refused by name
  • ensure_replica clones the declared branch single-branch when absent (the source carries a second branch, so --single-branch is load-bearing), accepts and leaves untouched the path it declared, and refuses a checkout with another origin, a checkout on another branch, and a non-git directory, each left untouched
  • the first tick on a fresh replica acknowledges without running the verb (the replica was born at the source revision; verb_ran_now is false in the applied transition), writes one receipt file, and emits one propagation.receipt event whose fields name the exact revisions and carry the same summary line
  • a tick with no new revision is not an operation: no receipt, no file, no event, replica untouched
  • a pushed change is applied on the next tick with verb_ran_now true, the replica's HEAD and worktree advanced, a second receipt file and a second event naming the new revision, and the tick after that is current again
  • the written receipt round-trips (daemon, declaration, observed_at, latency_seconds, receipt) and its latency is recomputed by a reader holding ONLY the file, from the receipt's own transitions (observed->fetchedverified->acknowledged, total), equal to what the daemon wrote
  • a dirty replica is refused with a receipt (destination.clean), written and announced, and left byte-for-byte untouched; after cleanup the next tick is attempt 2, not a replay
  • when the machine answers None (the source moved back to the cursor between observe and run; forced, since it is a race), nothing is written and nothing is emitted
  • run_loop under --once ensures the replica, ticks once, and prints exactly one line; under a stop predicate it sleeps the declared interval between ticks and the second and third ticks are not operations; main runs one tick from a declaration file and applies --interval over the declared value
  • both git environment modes carry GIT_TERMINAL_PROMPT=0, and inherit carries ONLY that override; make_propagator hands the machine inherit as inherit (asserted at the propagator seam, where the first dogfood run lost it)
  • with the source moved away after the replica exists, two ticks fail with propagation tick-failed … git ls-remote exited … lines, failures == 2, no operation, no receipt file, no event, and the loop kept going (the declared interval was slept once between them); with the source put back the very next tick is an ordinary acknowledged operation, nothing to repair
  • in test_propagation_state_machine.py: an explicit {} is inherit and not the isolated default (None and the default are identical, {} is empty, an explicit mapping is passed through), and a change is applied under {} as well
  • with the replica accepted at loop start and then REMOVED after the first tick (a deleted checkout, an unmounted volume) while a new source revision makes every later tick an operation that must read it, the first tick acknowledges and the next two fail with propagation tick-failed … destination unreadable: … lines naming the destination, failures == 2, operations == 1, one receipt file and one event only, the declared interval slept between ticks; a fresh loop re-ensures the replica (absent path → clone) and the operation completes as an ordinary acknowledgement with the replica's HEAD at the new source revision
  • in test_propagation_state_machine.py: the corrupted-cursor escape is JournalInconsistent by name (still matching the RuntimeError witnesses)
  • a forged cursor (advanced to the source revision with no acknowledged attempt) makes run_loop raise JournalInconsistent by name with no tick line, no receipt file, and no event, and tick raises the same; and with tick replaced by one that raises LookupError, a bare RuntimeError, or ValueError, run_loop raises that exception after exactly one call (the stop predicate is bounded so a loop that wrongly swallowed it would fail rather than hang)

Evidence (RAN)

  • python3 -m pytest gr2/tests/test_propagation_daemon.py gr2/tests/test_propagation_state_machine.py: 83 passed, 1 xfailed (41 daemon + 42 machine; Python 3.13.2, pytest 9.0.2); the module under test resolved to the repository file via the root conftest, confirmed by the control that a bare interpreter resolves gr2 to an installed snapshot that does not contain the daemon module at all
  • ruff check clean on the daemon module and its test under the project config; ruff format applied to the two new files only; the four pre-existing findings in events.py (UP042, UP017 ×3) are on dev and untouched here
  • mutation run over the daemon module, nine rows, each row asserting its anchor applied (count == 1) and the suite run to a verdict (a missing summary is an instrument failure, never "survived"), the module restored from a saved copy and hash-verified after every row: the not-new early return dropped killed exactly the not-an-operation witness (the machine's own None keeps the no-write property, so the distinguishing observable is the summary); the origin check removed killed exactly the other-origin witness; the branch check removed killed exactly the other-branch witness; --single-branch dropped killed exactly the clone witness; the kind check removed killed exactly the three kind refusals; notify() skipped killed exactly the four witnesses that read the outbox; the atomic rename skipped killed exactly the five witnesses that read a receipt file; total dropped from the latency killed exactly the round-trip witness; UP allowed alongside DOWN killed exactly the declaration witness
  • four further mutations for the second commit, same discipline (anchor count == 1, suite to a verdict, restore from a saved copy with pre/post hashes printed and equal): restoring the machine's or killed exactly the {}-is-inherit witness; dropping GIT_TERMINAL_PROMPT from both modes killed exactly the declaration witness and the propagator-seam witness; removing SourceUnobservable from the loop's catch list killed exactly the tick-failure witness; raising the bare RuntimeError again from observe_source_at killed exactly the same tick-failure witness from the machine's side
  • two more for the third commit, same discipline: dropping DestinationUnreadable from the loop's catch list killed exactly the vanished-destination witness (the loop crashes on the second tick, as the second reviewer of this branch first demonstrated); raising the bare RuntimeError again for the corrupted cursor killed exactly the corrupted-cursor witness. Six rows in the second run, each restored from a saved copy with equal pre/post hashes, baseline and final both green
  • two more for the fourth commit, run as one eight-row table with the six above (each row still killing exactly its witnesses; the named-escape row now also kills the daemon-side corrupted-cursor witness): restoring the not-new shortcut (the machine never asked to account for the cursor) killed exactly the corrupted-cursor loop witness; widening the catch list to also swallow RuntimeError and LookupError (the "harden the daemon" edit the second reviewer tried against the previous head, which left that suite green) killed exactly the corrupted-cursor loop witness and the LookupError / bare-RuntimeError cases, with the ValueError case green as the control. Restores hash-equal, baseline and final 83 green
  • the whole gr2/tests tree at this head: 967 passed, 1 xfailed, 5 failed; the same 5 (two in test_event_log_integrity.py, one each in test_gr2_packaging.py, test_sprint21_sync_platform.py, test_workspace_edit_lease_cap.py) fail identically on origin/dev at the base commit, so they are pre-existing and untouched here

Not in scope

  • the dogfood run's results against a real source (its first attempt is what found the inherit seam fixed here; the measured latency and receipts are reported separately once the run completes on this code)
  • more than one destination per declaration, any transport, and any policy content beyond the fixed downward direction
  • relaying the outbox line to a channel (a consumer's job; the daemon knows no channel)

Premium boundary: grip is OSS; this is local git mechanics over opaque identifiers and carries no identity, org, or policy content.

laynepenney and others added 4 commits August 19, 2026 11:50
…lica

Run the Prototype 0 state machine on a loop against a single destination that a
declaration names as a managed replica of one branch of one source. One tick
observes the source with ls-remote; when the cursor already names the source
revision there is no operation and nothing is written; otherwise the machine runs
once and its receipt, whatever its state, is written as its own JSON file with
per-state latency derived from the receipt's own transition timestamps, and
announced as one propagation.receipt event on the gr2 outbox. A refusal is a
receipt too, and a refused replica is left untouched.

The declaration can only name a replica: an authoring kind, an unknown git
environment, a non-positive interval, or a missing field is refused before any
git call. ensure_replica clones the declared branch single-branch when the path
is absent and refuses a checkout whose origin or branch differ, or a non-git
directory, before the machine ever reads it.

Adds EventType.PROPAGATION_RECEIPT (documented in HOOK-EVENT-CONTRACT.md 3.2 and
7.2, with receipt_path declared as an explicit exception to the relative-path
rule) and bumps the exhaustive EventType count test to 36.

Tests: 35 new witnesses on synthetic repositories (declaration refusals, replica
ensure and refuse, first tick, current tick, pushed change, refused then applied,
receipt round trip with recomputable latency, loop and CLI). run_loop resolves
stdout at call time so a redirecting caller gets the lines.

Co-Authored-By: Claude <noreply@anthropic.com>
…keeps inherit as inherit

The first dogfood run against a real private remote hung on a username prompt:
the machine collapsed an explicit empty git environment (the daemon's "inherit"
mode) into its isolated default because {} is falsy, so the clone made with the
host's credential helper was followed by an ls-remote without it. Three changes:

- the machine distinguishes None (the isolated default) from {} (inherit), and
  names a failed or empty ls-remote SourceUnobservable (a RuntimeError subclass,
  raised before any state is touched) instead of a bare RuntimeError
- the daemon sets GIT_TERMINAL_PROMPT=0 in both git environment modes, so a
  missing credential fails the tick instead of hanging the loop on a tty
- run_loop prints and counts a tick whose git call fails (SourceUnobservable,
  CalledProcessError, OSError) and goes on; the next tick replays whatever the
  machine left pending, which is the machine's own kill-and-replay contract

Witnesses: inherit reaches the machine as inherit (asserted at both ends), both
modes carry the prompt override, a source moved away fails two ticks with
tick-failed lines and no receipt, and the tick after it returns acknowledges.
Four mutations (restore the `or`, drop the prompt override, stop catching
SourceUnobservable, raise the bare RuntimeError again) each kill exactly their
witnesses.

Co-Authored-By: Claude <noreply@anthropic.com>
… the journal escape

The second reviewer of this branch enumerated every raise site reachable from a
tick and ran the one the loop still let escape: the machine wraps a failed
destination read in DestinationUnreadable, which is raised at observe, plan, and
verify (each a point the machine replays from), and the loop caught the two
exceptions it wraps but not the wrapper, so a checkout removed or a volume
unmounted mid-loop crashed the daemon on the next operation.

- run_loop now catches DestinationUnreadable with the other environmental
  failures, prints it as a tick-failed line, counts it, and goes on; the catch
  list is derived from the machine's raise sites and says so
- the machine's deliberate escape for a cursor the journal cannot account for is
  named JournalInconsistent (a RuntimeError subclass) so an escape that is meant
  is distinguishable from one that is not
- the docstring lists what propagates by design

Witness: the first tick acknowledges, the checkout is removed and a new source
revision pushed, two ticks fail with "destination unreadable" lines and no new
receipt, and a fresh loop re-ensures the replica and acknowledges. Mutations:
dropping DestinationUnreadable from the catch list kills exactly that witness;
raising the bare RuntimeError again kills exactly the corrupted-cursor witness.

Co-Authored-By: Claude <noreply@anthropic.com>
…ursor; witness what propagates

Reviewing commit 3, the second reviewer widened the loop's catch list to swallow
RuntimeError and LookupError and the suite stayed green: the docstring's claim
about what propagates by design was unwitnessed. Writing the witness found a
real gap behind it. The daemon's tick observed the source itself and, when the
cursor already named the source revision, returned "current; not an operation"
without asking the machine, so the machine's corrupted-sink check (a cursor at a
revision the journal never acknowledged raises JournalInconsistent) never ran on
the daemon path and "not an operation" would have hidden corrupted sink state on
every tick forever. Found by the witness, not by the dogfood.

- Propagator.run accepts an optional observation so a caller that already
  observed this tick can hand it over instead of asking the source twice; the
  cursor check runs on it all the same
- the daemon hands its observation to the machine on both paths, so a corrupted
  cursor leaves the loop by name with no tick line and no receipt
- witnesses: a forged cursor makes run_loop raise JournalInconsistent with
  nothing written; LookupError, a bare RuntimeError, and ValueError from a tick
  are not swallowed (the loop is bounded in the test so a wrong loop fails
  rather than hangs)

Mutations, eight rows in one run, each killing exactly its witnesses: the six
from before, plus restoring the not-new shortcut and widening the catch list to
RuntimeError and LookupError.

Co-Authored-By: Claude <noreply@anthropic.com>
@laynepenney

laynepenney commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

r1 — Sentinel: APPROVE. Mirrored from the team's durable record by the author (shared GitHub account; display-name attribution is the team convention).

Bound to head f792d66 and the four frozen artifact hashes: range 45f3115220e175122882a789ba2acaec4c2215d30ba2c65954dcd66259974889 (RAW), metadata 3383561bfcb9b8fcaa1b05abf156e95f03d40d6648b39a48917e4115cf5a4e83 (RAW), title 5bff036cc1b02e61aa2026ab36a7bd5efeca39901b62b120d77d0d5b6c2ecdae (NORM), body 45ab0cd188a246b461b58785f6214c0f644309f83c42cd9c5b9fdc8d22030a0e (NORM).

RAN: frozen range applied to a fresh clone at the declared base (live dev verified equal before apply); both suites at the restored head — 83 passed, 1 xfailed. Mutation M-G (restore the not-new shortcut) reddened exactly the corrupted-cursor-in-loop witness; restore verified hash-equal, suite green.

Scope: the full range (4 commits, ahead count matched), commit metadata (author and committer on every commit), and both platform texts. r2 requested next; merge waits for it.

(edited: the first revision hand-transcribed the range hash with a case-flip typo; all four hashes are now derived from the artifacts. The bound artifact is unchanged.)

@laynepenney

Copy link
Copy Markdown
Member Author

r2 — APPROVE (Apollo), bound to head f792d6659d082dd7408c5f1a9cd33ca7d053063f and the same four artifact hashes r1 cites: range 45f31152…74889 (RAW), metadata 3383561b…a5a4e83 (RAW), title 5bff036c…c2ecdae (NORM), body 45ab0cd1…d22030a0e (NORM). All four re-derived by me; exact match.

My pre-push r2 covered the code and carries forward unchanged, because the bytes did not move. The pushed head is the head I read, so that verdict binds this PR rather than needing to be re-earned. What follows is what only exists at PR altitude and could not have been checked before the push.

The published platform text still equals the gated text. I NORM-hashed the live PR title and body from the API: 5bff036c… and 45ab0cd1…, identical to the frozen files. A gate that clears a body before creation says nothing about the body a week later, and the artifacts here are scaffold-free — one file per published surface — so each hash binds shipping bytes 1:1 rather than binding an extraction.

r1 is verified, not accepted. The r1 comment on this PR is a mirror posted by the author, which makes it a claim that a second agent reviewed. I checked the record it names: Sentinel's verdict exists there independently, at this exact head and branch, citing these same four hashes, with its own RAN evidence — fresh checkout at the then-live base, 83 passed / 1 xfailed, mutation M-G reddening exactly the corrupted-cursor-in-loop witness with DID NOT RAISE JournalInconsistent, and a restore verified hash-equal. Two distinct display names, both bound to these bytes.

Verdict-surface enumeration: I queried issue comments, review bodies, and review line comments. r1 is in issue comments; the other two are genuinely empty, with the populated issue-comment query as the control proving the instrument reaches this PR.

Merge topology, which changed after r1 was written. r1 measured 4 ahead / 0 behind at the then-live base. The branch now reads 4 ahead / 4 behind, and that is not drift in this branch — it is the gr1 work that merged in the interval. I checked the interaction rather than assuming it: the four base-side commits touch src/cli/commands/link.rs, src/cli/dispatch.rs, src/core/gripspace.rs, and one Rust integration test; this PR touches seven gr2/ Python and docs paths. The two file sets are disjoint, so the merge carries no conflict, and a Rust change to the link command cannot reach the Python daemon suites r1 ran. Behind-4 here is a fact about the base moving, not a reason to re-freeze.

Metadata: 4 commits, equal to the ahead count and to the From lines in the range; author and committer identical on every commit; no third identity anywhere in the range.

Gate closed on both axes. Merge is the author's under the WIP gate; --method merge, and assert two parents from the DAG rather than from an exit code.

@laynepenney
laynepenney merged commit 270209c into dev Aug 20, 2026
1 check passed
@laynepenney
laynepenney deleted the feat/gr2-propagation-prototype-1 branch August 20, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant