gitgrip 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,1478 @@
1
+ """Prototype 0: a propagation state machine over git repositories.
2
+
3
+ This prototype proves the state contract for moving one change from a source
4
+ repository to one or more destination clones, before any daemon is allowed near
5
+ a real authoring clone. It is deliberately neutral: every identifier it carries
6
+ (source, destination, layer, artifact class, policy hash) is an opaque string
7
+ the caller resolves; the machine interprets none of them and holds no notion of
8
+ who an agent or a workspace is.
9
+
10
+ The one rule the contract enforces:
11
+
12
+ Every state transition names the observation that establishes it, and that
13
+ observation must be capable of returning a different answer.
14
+
15
+ States, in order, each with the observation that establishes it:
16
+
17
+ observed a source change was detected at a named revision
18
+ fetched the object is present in the sink's mirror and its digest was
19
+ recomputed from the object itself
20
+ planned a plan exists, bound to the destination base it was computed
21
+ against, with every gate recorded individually
22
+ applied the destination itself reports the intended revision (read
23
+ back from the destination, never from the verb's exit status)
24
+ verified the declared postcondition was checked by name and holds
25
+ acknowledged the source cursor advances, only now
26
+
27
+ and three more reachable from any of them:
28
+
29
+ refused a named refusal condition fired, and which one is recorded
30
+ partial some declared targets verified and others did not, both listed
31
+ unverifiable the effect may or may not have happened and no observation
32
+ available to the sink can tell; never collapsed into either
33
+ neighbour, because one invites a double apply and the other
34
+ invites work on an effect that may not exist
35
+
36
+ Idempotency and compare-and-swap:
37
+
38
+ * every operation has an id derived from (coordinate, source revision, expected
39
+ base, digest); the journal is keyed so a replay resumes the same operation
40
+ * every destination transition is compare-and-swap on the expected base; a
41
+ moved base refuses and reports what was observed, it never merges or forces
42
+ * the apply step tolerates exactly one benign discrepancy: a destination that
43
+ already reports the intended revision is recorded as applied without running
44
+ the verb again, which is how a sink killed mid-apply replays without applying
45
+ twice
46
+
47
+ Replay rules the journal implements:
48
+
49
+ * an acknowledged operation replays as a no-op returning the original outcome
50
+ * a refused operation is terminal for that attempt only: a later run at the same
51
+ source revision starts a new attempt, because a refusal describes a moment
52
+ (a dirty worktree, a moved base) and the author may have changed it
53
+ * anything else resumes from the last recorded state without skipping a state
54
+
55
+ Everything under ``state_dir`` belongs to the sink: ``journal.jsonl`` (append
56
+ only, one row per transition or note, fsynced), ``cursors/`` (one small file per
57
+ coordinate, written only on acknowledged), and ``mirror.git`` (a bare mirror the
58
+ sink fetches into, so destinations are read but never written before apply).
59
+
60
+ Fault injection for tests: ``kill_after`` raises :class:`SinkKilled` right after
61
+ the named state is journaled (or right after the apply verb ran, before the read
62
+ back, with ``KILL_AFTER_APPLY_VERB``); ``after_apply_verb`` runs a callable
63
+ between the verb and the read back so a destination can be made unreadable;
64
+ ``before_apply_verb`` runs a callable after the apply step's own head check and
65
+ before the verb, so a destination can be moved inside that window.
66
+
67
+ Prototype 2 (the contribution protocol) adds ONE destination kind and no new
68
+ state: :attr:`DestinationKind.CANONICAL` is a bare remote that OWNS the branch, and
69
+ an operation with ``direction=up`` lands on it by a fast-forward push guarded by
70
+ ``--force-with-lease`` on the expected base. The receiving repository enforces the
71
+ compare-and-swap; the plan's fast-forward gate guarantees the lease never forces;
72
+ a rejected lease is a REFUSAL carrying the revision the owner holds, not an error.
73
+ Replanning a refused contribution is the author's act — the machine never rebases,
74
+ merges, or forces on anyone's behalf.
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import hashlib
80
+ import json
81
+ import os
82
+ import subprocess
83
+ from collections.abc import Callable, Iterable
84
+ from dataclasses import asdict, dataclass, field
85
+ from datetime import UTC, datetime
86
+ from enum import StrEnum
87
+ from pathlib import Path
88
+
89
+ KILL_AFTER_APPLY_VERB = "apply-verb"
90
+
91
+ _GATE_IDS = (
92
+ "policy.direction",
93
+ "destination.base-unmoved",
94
+ "destination.clean",
95
+ "destination.fast-forward",
96
+ )
97
+ _POSTCONDITION = "head-is-intended-after-and-tree-matches-digest"
98
+ # A canonical destination is bare: the postcondition is about its BRANCH ref, and
99
+ # "clean" is not part of it because there is no worktree to be clean.
100
+ _POSTCONDITION_CANONICAL = "branch-is-intended-after-and-tree-matches-digest"
101
+ _GATE_NOT_RUN = "not-run"
102
+
103
+ # The prototype runs git without the user's global or system configuration so
104
+ # that signing, hook paths, and identity settings on the host cannot reach the
105
+ # synthetic repositories. Callers may pass their own environment.
106
+ _ISOLATED_GIT_ENV = {
107
+ "GIT_CONFIG_GLOBAL": os.devnull,
108
+ "GIT_CONFIG_NOSYSTEM": "1",
109
+ }
110
+
111
+
112
+ class SinkKilled(RuntimeError):
113
+ """The sink process died at a fault-injection point."""
114
+
115
+
116
+ class DestinationUnreadable(RuntimeError):
117
+ """A read against the destination returned an error instead of an answer."""
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class MalformedLine:
122
+ """A journal line that could not be parsed, kept so a drop is never silent.
123
+
124
+ ``index`` counts lines in the file, blank ones included, so it names the line a
125
+ reader would count to by hand.
126
+ """
127
+
128
+ index: int
129
+ excerpt: str
130
+ reason: str
131
+
132
+
133
+ class JournalInconsistent(RuntimeError):
134
+ """The cursor names a revision the journal cannot account for.
135
+
136
+ A corrupted sink state: the prototype says so and stops rather than guessing an
137
+ outcome. Raised deliberately and left to propagate by every caller, by name.
138
+ """
139
+
140
+
141
+ class SourceUnobservable(RuntimeError):
142
+ """``git ls-remote`` against the source failed or advertised no such branch.
143
+
144
+ Raised before any cursor, journal, or destination state is touched, so a caller that
145
+ loops (a daemon) can count it and try again on its next tick with nothing to repair.
146
+ A ``RuntimeError`` subclass, so a caller that caught the bare class still does.
147
+ """
148
+
149
+
150
+ class Direction(StrEnum):
151
+ DOWN = "down"
152
+ UP = "up"
153
+ ACROSS = "across"
154
+
155
+
156
+ class Operation(StrEnum):
157
+ APPLY = "apply"
158
+ CONTRIBUTE = "contribute"
159
+ OBSERVE = "observe"
160
+
161
+
162
+ class DestinationKind(StrEnum):
163
+ REPLICA = "replica"
164
+ AUTHORING = "authoring"
165
+ # Prototype 2 (the contribution protocol): a bare remote that OWNS the branch and
166
+ # accepts a contribution by a fast-forward push guarded by ``--force-with-lease`` on the
167
+ # expected base — compare-and-swap enforced by the receiving repository, not by
168
+ # this process. It has no worktree, so cleanliness is not a property it has.
169
+ CANONICAL = "canonical"
170
+
171
+
172
+ class State(StrEnum):
173
+ OBSERVED = "observed"
174
+ FETCHED = "fetched"
175
+ PLANNED = "planned"
176
+ APPLIED = "applied"
177
+ VERIFIED = "verified"
178
+ ACKNOWLEDGED = "acknowledged"
179
+ REFUSED = "refused"
180
+ PARTIAL = "partial"
181
+ UNVERIFIABLE = "unverifiable"
182
+
183
+
184
+ _ORDERED = (
185
+ State.OBSERVED,
186
+ State.FETCHED,
187
+ State.PLANNED,
188
+ State.APPLIED,
189
+ State.VERIFIED,
190
+ State.ACKNOWLEDGED,
191
+ )
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class Coordinate:
196
+ source: str
197
+ destination: str
198
+ layer: str
199
+ direction: Direction
200
+ operation: Operation
201
+ artifact_class: str
202
+
203
+ def key(self) -> str:
204
+ """Canonical, injective key: the JSON of ``as_dict()`` with sorted keys.
205
+
206
+ The fields are OPAQUE identifiers, so no delimiter can be assumed absent
207
+ from them. A ``"|".join`` made ``("source|dest", "target", ...)`` and
208
+ ``("source", "dest|target", ...)`` the same key, and the key names the
209
+ journal rows and the cursor file, so two coordinates would have shared
210
+ replay state (found in review of the first cut). JSON string encoding
211
+ escapes every byte that could be mistaken for structure, so the key
212
+ round-trips: ``json.loads(key) == as_dict()``. Distinct coordinates
213
+ therefore cannot collide, and the witness asserts the round-trip rather
214
+ than any particular pair.
215
+ """
216
+ return json.dumps(self.as_dict(), sort_keys=True, separators=(",", ":"))
217
+
218
+ @classmethod
219
+ def from_key(cls, key: str) -> Coordinate:
220
+ data = json.loads(key)
221
+ return cls(
222
+ source=str(data["source"]),
223
+ destination=str(data["destination"]),
224
+ layer=str(data["layer"]),
225
+ direction=Direction(str(data["direction"])),
226
+ operation=Operation(str(data["operation"])),
227
+ artifact_class=str(data["artifact_class"]),
228
+ )
229
+
230
+ def as_dict(self) -> dict[str, str]:
231
+ return {
232
+ "source": self.source,
233
+ "destination": self.destination,
234
+ "layer": self.layer,
235
+ "direction": str(self.direction),
236
+ "operation": str(self.operation),
237
+ "artifact_class": self.artifact_class,
238
+ }
239
+
240
+
241
+ @dataclass(frozen=True)
242
+ class Destination:
243
+ destination_id: str
244
+ path: Path
245
+ kind: DestinationKind
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class Policy:
250
+ """Opaque policy inputs. The caller resolves them; the machine only applies them.
251
+
252
+ ``allowed_directions`` has no default on purpose: a permissive default would
253
+ be the decision, whoever wrote it.
254
+ """
255
+
256
+ policy_hash: str
257
+ allowed_directions: frozenset[Direction]
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class GateResult:
262
+ gate_id: str
263
+ result: str
264
+ detail: str
265
+ result_hash: str
266
+
267
+ def as_dict(self) -> dict[str, str]:
268
+ return asdict(self)
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class Transition:
273
+ state: State
274
+ observation: dict[str, object]
275
+ timestamp: str
276
+ operation_id: str | None
277
+
278
+ def as_dict(self) -> dict[str, object]:
279
+ return {
280
+ "state": str(self.state),
281
+ "observation": self.observation,
282
+ "timestamp": self.timestamp,
283
+ "operation_id": self.operation_id,
284
+ }
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class Observation:
289
+ source_rev: str
290
+ cursor: str | None
291
+ is_new: bool
292
+
293
+
294
+ @dataclass(frozen=True)
295
+ class Receipt:
296
+ pending_id: str
297
+ attempt: int
298
+ operation_id: str | None
299
+ coordinate: Coordinate
300
+ source_rev: str
301
+ expected_base: str
302
+ observed_base: str | None
303
+ after: str | None
304
+ digest: str | None
305
+ plan_hash: str | None
306
+ policy_hash: str
307
+ gate_results: tuple[GateResult, ...]
308
+ state: State
309
+ postcondition_checked: str | None
310
+ postcondition_holds: bool | None
311
+ timestamp: str
312
+ refusal_reason: str | None
313
+ detail: str
314
+ transitions: tuple[Transition, ...]
315
+ replayed: bool
316
+
317
+ def as_dict(self) -> dict[str, object]:
318
+ return {
319
+ "pending_id": self.pending_id,
320
+ "attempt": self.attempt,
321
+ "operation_id": self.operation_id,
322
+ "coordinate": self.coordinate.as_dict(),
323
+ "source_rev": self.source_rev,
324
+ "expected_base": self.expected_base,
325
+ "observed_base": self.observed_base,
326
+ "after": self.after,
327
+ "digest": self.digest,
328
+ "plan_hash": self.plan_hash,
329
+ "policy_hash": self.policy_hash,
330
+ "gate_results": [g.as_dict() for g in self.gate_results],
331
+ "state": str(self.state),
332
+ "postcondition_checked": self.postcondition_checked,
333
+ "postcondition_holds": self.postcondition_holds,
334
+ "timestamp": self.timestamp,
335
+ "refusal_reason": self.refusal_reason,
336
+ "detail": self.detail,
337
+ "transitions": [t.as_dict() for t in self.transitions],
338
+ "replayed": self.replayed,
339
+ }
340
+
341
+
342
+ @dataclass(frozen=True)
343
+ class PlanOutcome:
344
+ state: State
345
+ reached: tuple[str, ...]
346
+ not_reached: tuple[tuple[str, str], ...]
347
+ receipts: tuple[Receipt, ...]
348
+
349
+
350
+ # --------------------------------------------------------------------------- helpers
351
+
352
+
353
+ def _now() -> str:
354
+ return datetime.now(UTC).isoformat()
355
+
356
+
357
+ def _sha256_json(obj: object) -> str:
358
+ return hashlib.sha256(
359
+ json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
360
+ ).hexdigest()
361
+
362
+
363
+ def operation_id_for(coordinate_key: str, source_rev: str, expected_base: str, digest: str) -> str:
364
+ return _sha256_json(
365
+ {
366
+ "coordinate": coordinate_key,
367
+ "source_rev": source_rev,
368
+ "expected_base": expected_base,
369
+ "digest": digest,
370
+ }
371
+ )
372
+
373
+
374
+ def pending_id_for(coordinate_key: str, source_rev: str, attempt: int) -> str:
375
+ return _sha256_json(
376
+ {"coordinate": coordinate_key, "source_rev": source_rev, "attempt": attempt}
377
+ )
378
+
379
+
380
+ def _git(repo: Path, *args: str, env: dict[str, str] | None = None) -> str:
381
+ proc = subprocess.run(
382
+ ["git", "-C", str(repo), *args],
383
+ capture_output=True,
384
+ text=True,
385
+ env={**os.environ, **(env or _ISOLATED_GIT_ENV)},
386
+ )
387
+ if proc.returncode != 0:
388
+ raise subprocess.CalledProcessError(proc.returncode, proc.args, proc.stdout, proc.stderr)
389
+ return proc.stdout.strip()
390
+
391
+
392
+ def tree_digest(repo: Path, rev: str, env: dict[str, str] | None = None) -> str:
393
+ """The content digest of a revision: its tree id, recomputed from the object store."""
394
+ return _git(repo, "rev-parse", f"{rev}^{{tree}}", env=env)
395
+
396
+
397
+ # --------------------------------------------------------------------------- journal
398
+
399
+
400
+ class Journal:
401
+ """Append-only record of transitions and notes, plus per-coordinate cursors."""
402
+
403
+ def __init__(self, state_dir: Path) -> None:
404
+ self.state_dir = state_dir
405
+ self.path = state_dir / "journal.jsonl"
406
+ self.cursors_dir = state_dir / "cursors"
407
+ # Rows are cached against the file's (size, mtime_ns) because Prototype 1 asks
408
+ # the machine to account for an observation on EVERY tick, including idle ones,
409
+ # which moved this parse from once-per-change to once-per-tick. The Propagator
410
+ # is built once per loop and holds one Journal, so the cache spans ticks.
411
+ #
412
+ # LIMITATION, stated rather than assumed away: an in-place mutation that changes
413
+ # neither size nor mtime_ns is not detected. The append-only writer never
414
+ # produces that state, and the next real append invalidates the entry — but a
415
+ # cache sitting on a corruption-detection path should name what it cannot see.
416
+ self._cache_key: tuple[int, int] | None = None
417
+ self._cached_rows: list[dict[str, object]] = []
418
+ self._malformed: tuple[MalformedLine, ...] = ()
419
+
420
+ @property
421
+ def malformed_lines(self) -> tuple[MalformedLine, ...]:
422
+ """Lines the last read could not parse. Empty is the ordinary answer."""
423
+ return self._malformed
424
+
425
+ # -- rows
426
+
427
+ def _rows(self) -> list[dict[str, object]]:
428
+ """Every readable row, oldest first. Unreadable lines are dropped and counted.
429
+
430
+ A kill between ``_write``'s write and its fsync leaves a partial line, so an
431
+ unparseable line is an ordinary consequence of the writer being killed — not
432
+ evidence of corruption, and not a reason to refuse. Raising here would exit the
433
+ daemon on its first tick and on every restart after it, because the loop lets
434
+ anything outside its named failure list propagate.
435
+
436
+ Position does NOT discriminate cause. A tear is trailing only until the daemon
437
+ restarts and appends again, after which the same orphan sits mid-file with intact
438
+ rows on both sides. The question that does discriminate is whether the CURSOR can
439
+ still be accounted for, and ``replay`` already asks it and already raises
440
+ ``JournalInconsistent`` by name. Dropping a line here lets that check be the one
441
+ that decides, instead of a bare ``ValueError`` from this parse pre-empting it.
442
+
443
+ Returned rows are shared with the cache and must be treated as read-only; every
444
+ consumer in this module filters or copies rather than mutating.
445
+ """
446
+ try:
447
+ stat = self.path.stat()
448
+ except (FileNotFoundError, NotADirectoryError):
449
+ self._cache_key, self._cached_rows, self._malformed = None, [], ()
450
+ return []
451
+
452
+ key = (stat.st_size, stat.st_mtime_ns)
453
+ if key == self._cache_key:
454
+ return self._cached_rows
455
+
456
+ rows: list[dict[str, object]] = []
457
+ malformed: list[MalformedLine] = []
458
+ for index, raw in enumerate(self.path.read_text().splitlines()):
459
+ line = raw.strip()
460
+ if not line:
461
+ continue
462
+ try:
463
+ rows.append(json.loads(line))
464
+ except json.JSONDecodeError as exc:
465
+ malformed.append(
466
+ MalformedLine(index=index, excerpt=line[:120], reason=str(exc))
467
+ )
468
+ self._cache_key, self._cached_rows, self._malformed = key, rows, tuple(malformed)
469
+ return rows
470
+
471
+ def _write(self, row: dict[str, object]) -> None:
472
+ """Append one row, terminating a torn remnant first.
473
+
474
+ Without the repair, appending onto a partial line GLUES this row to it, and the
475
+ tear costs two rows instead of one: the interrupted write, and the next write,
476
+ which was itself correct and fsynced. Terminating first confines the damage to
477
+ the line that was actually interrupted.
478
+
479
+ Two writers racing can at worst both terminate, leaving a blank line, which
480
+ ``_rows`` already skips.
481
+ """
482
+ self.path.parent.mkdir(parents=True, exist_ok=True)
483
+ unterminated = False
484
+ try:
485
+ with self.path.open("rb") as probe:
486
+ if probe.seek(0, os.SEEK_END):
487
+ probe.seek(-1, os.SEEK_END)
488
+ unterminated = probe.read(1) != b"\n"
489
+ except FileNotFoundError:
490
+ pass
491
+ with self.path.open("a") as handle:
492
+ if unterminated:
493
+ handle.write("\n")
494
+ handle.write(json.dumps(row, separators=(",", ":")) + "\n")
495
+ handle.flush()
496
+ os.fsync(handle.fileno())
497
+
498
+ def append(
499
+ self,
500
+ *,
501
+ pending_id: str,
502
+ attempt: int,
503
+ operation_id: str | None,
504
+ coordinate_key: str,
505
+ source_rev: str,
506
+ state: State,
507
+ observation: dict[str, object],
508
+ ) -> Transition:
509
+ transition = Transition(
510
+ state=state, observation=observation, timestamp=_now(), operation_id=operation_id
511
+ )
512
+ self._write(
513
+ {
514
+ "kind": "transition",
515
+ "pending_id": pending_id,
516
+ "attempt": attempt,
517
+ "operation_id": operation_id,
518
+ "coordinate_key": coordinate_key,
519
+ "source_rev": source_rev,
520
+ **transition.as_dict(),
521
+ }
522
+ )
523
+ return transition
524
+
525
+ def note(
526
+ self,
527
+ *,
528
+ pending_id: str,
529
+ attempt: int,
530
+ coordinate_key: str,
531
+ source_rev: str,
532
+ note: str,
533
+ data: dict[str, object] | None = None,
534
+ ) -> None:
535
+ self._write(
536
+ {
537
+ "kind": "note",
538
+ "pending_id": pending_id,
539
+ "attempt": attempt,
540
+ "coordinate_key": coordinate_key,
541
+ "source_rev": source_rev,
542
+ "note": note,
543
+ "data": data or {},
544
+ "timestamp": _now(),
545
+ }
546
+ )
547
+
548
+ def rows_for(self, coordinate_key: str, source_rev: str) -> list[dict[str, object]]:
549
+ return [
550
+ r
551
+ for r in self._rows()
552
+ if r.get("coordinate_key") == coordinate_key and r.get("source_rev") == source_rev
553
+ ]
554
+
555
+ def find(self, coordinate_key: str, source_rev: str) -> list[list[Transition]]:
556
+ """Every attempt at (coordinate, source revision), oldest first, each as its transitions."""
557
+ attempts: dict[int, list[Transition]] = {}
558
+ for row in self.rows_for(coordinate_key, source_rev):
559
+ if row.get("kind") != "transition":
560
+ continue
561
+ attempts.setdefault(int(row["attempt"]), []).append(
562
+ Transition(
563
+ state=State(str(row["state"])),
564
+ observation=dict(row["observation"]), # type: ignore[arg-type]
565
+ timestamp=str(row["timestamp"]),
566
+ operation_id=row.get("operation_id"), # type: ignore[arg-type]
567
+ )
568
+ )
569
+ return [attempts[k] for k in sorted(attempts)]
570
+
571
+ def notes(self, coordinate_key: str, source_rev: str, note: str) -> int:
572
+ return sum(
573
+ 1
574
+ for r in self.rows_for(coordinate_key, source_rev)
575
+ if r.get("kind") == "note" and r.get("note") == note
576
+ )
577
+
578
+ # -- cursors
579
+
580
+ def cursor_path(self, coordinate_key: str) -> Path:
581
+ return self.cursors_dir / (hashlib.sha256(coordinate_key.encode()).hexdigest() + ".json")
582
+
583
+ def cursor(self, coordinate_key: str) -> str | None:
584
+ path = self.cursor_path(coordinate_key)
585
+ if not path.exists():
586
+ return None
587
+ try:
588
+ data = json.loads(path.read_text())
589
+ except (json.JSONDecodeError, OSError):
590
+ return None
591
+ value = data.get("source_rev")
592
+ return str(value) if value else None
593
+
594
+ def advance_cursor(self, coordinate_key: str, source_rev: str, pending_id: str) -> None:
595
+ self.cursors_dir.mkdir(parents=True, exist_ok=True)
596
+ path = self.cursor_path(coordinate_key)
597
+ tmp = path.with_suffix(".tmp")
598
+ tmp.write_text(
599
+ json.dumps(
600
+ {
601
+ "coordinate_key": coordinate_key,
602
+ "source_rev": source_rev,
603
+ "pending_id": pending_id,
604
+ "advanced_at": _now(),
605
+ },
606
+ indent=2,
607
+ )
608
+ )
609
+ with tmp.open("rb") as handle:
610
+ os.fsync(handle.fileno())
611
+ tmp.rename(path)
612
+
613
+
614
+ # ------------------------------------------------------------------------ the machine
615
+
616
+
617
+ @dataclass
618
+ class _Op:
619
+ """The in-flight operation, reconstructed from the journal on resume."""
620
+
621
+ coordinate: Coordinate
622
+ destination: Destination
623
+ source_rev: str
624
+ attempt: int
625
+ pending_id: str
626
+ previous_cursor: str | None
627
+ expected_base: str | None = None
628
+ digest: str | None = None
629
+ operation_id: str | None = None
630
+ observed_base: str | None = None
631
+ intended_after: str | None = None
632
+ plan_hash: str | None = None
633
+ gate_results: tuple[GateResult, ...] = ()
634
+ transitions: list[Transition] = field(default_factory=list)
635
+
636
+ @property
637
+ def key(self) -> str:
638
+ return self.coordinate.key()
639
+
640
+
641
+ class Propagator:
642
+ def __init__(
643
+ self,
644
+ source_remote: Path,
645
+ branch: str,
646
+ state_dir: Path,
647
+ policy: Policy,
648
+ *,
649
+ kill_after: State | str | None = None,
650
+ after_apply_verb: Callable[[], None] | None = None,
651
+ before_apply_verb: Callable[[], None] | None = None,
652
+ git_env: dict[str, str] | None = None,
653
+ ) -> None:
654
+ self.source_remote = source_remote
655
+ self.branch = branch
656
+ self.state_dir = state_dir
657
+ self.policy = policy
658
+ self.kill_after = kill_after
659
+ self.after_apply_verb = after_apply_verb
660
+ # Prototype 2 test seam: runs AFTER the apply step's own head check and BEFORE
661
+ # the verb, so a test can move a canonical destination inside the window the
662
+ # process-side check cannot see. The lease is what must catch that move.
663
+ self.before_apply_verb = before_apply_verb
664
+ # None means "the default, isolated from the host"; an explicit {} means "inherit the
665
+ # host environment" and must not collapse into the default because it is falsy
666
+ self.git_env = _ISOLATED_GIT_ENV if git_env is None else git_env
667
+ self.journal = Journal(state_dir)
668
+ self.mirror = state_dir / "mirror.git"
669
+
670
+ # -- observation of the source
671
+
672
+ def observe_source_at(self, cursor: str | None) -> Observation:
673
+ out = subprocess.run(
674
+ ["git", "ls-remote", str(self.source_remote), f"refs/heads/{self.branch}"],
675
+ capture_output=True,
676
+ text=True,
677
+ env={**os.environ, **self.git_env},
678
+ )
679
+ if out.returncode != 0 or not out.stdout.strip():
680
+ stderr = out.stderr.strip().splitlines()
681
+ tail = stderr[-1] if stderr else "no output"
682
+ raise SourceUnobservable(
683
+ f"git ls-remote exited {out.returncode} for {self.source_remote} "
684
+ f"refs/heads/{self.branch}: {tail}"
685
+ )
686
+ source_rev = out.stdout.split()[0]
687
+ return Observation(source_rev=source_rev, cursor=cursor, is_new=(source_rev != cursor))
688
+
689
+ def observe_source(self, coordinate: Coordinate) -> Observation:
690
+ return self.observe_source_at(self.journal.cursor(coordinate.key()))
691
+
692
+ # -- destination reads (never writes before apply)
693
+
694
+ def read_head(self, destination: Destination) -> str:
695
+ # A canonical destination is read at its BRANCH ref, never HEAD: a bare
696
+ # repository's HEAD is a symref that may point anywhere, and the thing a
697
+ # contribution lands on is the branch the owner declared.
698
+ ref = (
699
+ f"refs/heads/{self.branch}" if destination.kind is DestinationKind.CANONICAL else "HEAD"
700
+ )
701
+ try:
702
+ return _git(destination.path, "rev-parse", "--verify", ref, env=self.git_env)
703
+ except (subprocess.CalledProcessError, OSError) as exc:
704
+ raise DestinationUnreadable(f"{destination.destination_id}: {exc}") from exc
705
+
706
+ def _porcelain(self, destination: Destination) -> str:
707
+ return _git(destination.path, "status", "--porcelain", env=self.git_env)
708
+
709
+ def _observed_ref_source(self, destination: Destination) -> str:
710
+ """The ref the mirror fetches for ahead/behind: the branch for a canonical, else HEAD."""
711
+ if destination.kind is DestinationKind.CANONICAL:
712
+ return f"refs/heads/{self.branch}"
713
+ return "HEAD"
714
+
715
+ # -- driver
716
+
717
+ def run(
718
+ self,
719
+ coordinate: Coordinate,
720
+ destination: Destination,
721
+ observation: Observation | None = None,
722
+ *,
723
+ stop_after: State | None = None,
724
+ ) -> Receipt | None:
725
+ """Drive one coordinate. ``None`` means no new source revision: not an operation.
726
+
727
+ Raises ``JournalInconsistent`` if the cursor sits at a revision the journal never
728
+ acknowledged. A caller that has already observed the source this tick may pass
729
+ its ``observation`` so the source is not asked twice; the cursor check runs on it
730
+ all the same, because "nothing new" is exactly the answer that would hide a
731
+ corrupted sink state, whoever observed.
732
+
733
+ ``stop_after=State.PLANNED`` drives the operation through its gates and stops
734
+ BEFORE the apply verb, journaled at ``planned``; a later ``run`` resumes it from
735
+ there. This is how a contribution SET prepares every member against its owner's
736
+ current base before landing any of them (Prototype 2).
737
+ """
738
+ return self._run(
739
+ coordinate,
740
+ destination,
741
+ report_current=False,
742
+ observation=observation,
743
+ stop_after=stop_after,
744
+ )
745
+
746
+ def _run(
747
+ self,
748
+ coordinate: Coordinate,
749
+ destination: Destination,
750
+ *,
751
+ report_current: bool,
752
+ observation: Observation | None = None,
753
+ stop_after: State | None = None,
754
+ ) -> Receipt | None:
755
+ key = coordinate.key()
756
+ if observation is None:
757
+ observation = self.observe_source(coordinate)
758
+ if not observation.is_new:
759
+ # The cursor is AT the source revision, which only happens after an
760
+ # acknowledgement at that revision; _terminal_receipt RAISES if the
761
+ # journal carries no such acknowledgement, on both paths, because a
762
+ # cursor the journal cannot account for is a corrupted sink state and
763
+ # "nothing new" is exactly the answer that would hide it. For an
764
+ # aggregate the target was declared and already reached; report the
765
+ # terminal outcome rather than dropping the target, because a dropped
766
+ # target is indistinguishable from one never part of the invocation.
767
+ terminal = self._terminal_receipt(coordinate, observation.source_rev)
768
+ return terminal if report_current else None
769
+ source_rev = observation.source_rev
770
+
771
+ attempts = self.journal.find(key, source_rev)
772
+ if attempts:
773
+ last = attempts[-1]
774
+ last_state = last[-1].state
775
+ if last_state is State.ACKNOWLEDGED:
776
+ # terminal: the original outcome, no verb, and the cursor repaired if it lagged
777
+ attempt = len(attempts)
778
+ pending_id = pending_id_for(key, source_rev, attempt)
779
+ self.journal.note(
780
+ pending_id=pending_id,
781
+ attempt=attempt,
782
+ coordinate_key=key,
783
+ source_rev=source_rev,
784
+ note="replayed-terminal",
785
+ data={"state": str(last_state)},
786
+ )
787
+ self.journal.advance_cursor(key, source_rev, pending_id)
788
+ return self._receipt(coordinate, source_rev, attempt, replayed=True)
789
+ if last_state is State.REFUSED:
790
+ op = self._fresh(
791
+ coordinate,
792
+ destination,
793
+ source_rev,
794
+ observation.cursor,
795
+ attempt=len(attempts) + 1,
796
+ )
797
+ else:
798
+ op = self._resume(
799
+ coordinate,
800
+ destination,
801
+ source_rev,
802
+ observation.cursor,
803
+ attempt=len(attempts),
804
+ transitions=last,
805
+ )
806
+ else:
807
+ op = self._fresh(coordinate, destination, source_rev, observation.cursor, attempt=1)
808
+
809
+ self._drive(op, stop_after=stop_after)
810
+ return self._receipt(coordinate, source_rev, op.attempt, replayed=False)
811
+
812
+ def _terminal_receipt(self, coordinate: Coordinate, source_rev: str) -> Receipt:
813
+ """The original outcome for a coordinate whose cursor is already at ``source_rev``."""
814
+ attempts = self.journal.find(coordinate.key(), source_rev)
815
+ if not attempts or attempts[-1][-1].state is not State.ACKNOWLEDGED:
816
+ # A cursor at a revision the journal never acknowledged is a corrupted
817
+ # sink state, and the prototype says so rather than guessing an outcome.
818
+ raise JournalInconsistent(
819
+ f"cursor for {coordinate.destination} is at {source_rev} but the journal "
820
+ "carries no acknowledged attempt at that revision"
821
+ )
822
+ return self._receipt(coordinate, source_rev, len(attempts), replayed=True)
823
+
824
+ def run_all(self, targets: Iterable[tuple[Coordinate, Destination]]) -> PlanOutcome | None:
825
+ """Drive every declared target and classify the aggregate.
826
+
827
+ A declared target whose cursor is already at the source revision was reached
828
+ by an earlier invocation; it is reported as reached with its terminal receipt
829
+ (``replayed=True``), never dropped. Dropping it would reclassify a replayed
830
+ ``partial`` as ``refused``, which is what the first cut did (found in review):
831
+ the aggregate must distinguish "already reached" from "not part of this
832
+ invocation", and the only way to do that is to keep reporting it.
833
+ ``None`` only when no targets were declared.
834
+ """
835
+ receipts: list[Receipt] = []
836
+ for coordinate, destination in targets:
837
+ receipt = self._run(coordinate, destination, report_current=True)
838
+ if receipt is not None:
839
+ receipts.append(receipt)
840
+ if not receipts:
841
+ return None
842
+ reached = tuple(r.coordinate.destination for r in receipts if r.state is State.ACKNOWLEDGED)
843
+ not_reached = tuple(
844
+ (r.coordinate.destination, r.refusal_reason or r.detail or f"state={r.state}")
845
+ for r in receipts
846
+ if r.state is not State.ACKNOWLEDGED
847
+ )
848
+ if not not_reached:
849
+ state = State.ACKNOWLEDGED
850
+ elif not reached:
851
+ state = State.REFUSED
852
+ else:
853
+ state = State.PARTIAL
854
+ return PlanOutcome(
855
+ state=state, reached=reached, not_reached=not_reached, receipts=tuple(receipts)
856
+ )
857
+
858
+ # -- attempt construction
859
+
860
+ def _fresh(
861
+ self,
862
+ coordinate: Coordinate,
863
+ destination: Destination,
864
+ source_rev: str,
865
+ cursor: str | None,
866
+ *,
867
+ attempt: int,
868
+ ) -> _Op:
869
+ return _Op(
870
+ coordinate=coordinate,
871
+ destination=destination,
872
+ source_rev=source_rev,
873
+ attempt=attempt,
874
+ pending_id=pending_id_for(coordinate.key(), source_rev, attempt),
875
+ previous_cursor=cursor,
876
+ )
877
+
878
+ def _resume(
879
+ self,
880
+ coordinate: Coordinate,
881
+ destination: Destination,
882
+ source_rev: str,
883
+ cursor: str | None,
884
+ *,
885
+ attempt: int,
886
+ transitions: list[Transition],
887
+ ) -> _Op:
888
+ op = self._fresh(coordinate, destination, source_rev, cursor, attempt=attempt)
889
+ op.transitions = list(transitions)
890
+ for t in transitions:
891
+ obs = t.observation
892
+ if t.state is State.OBSERVED:
893
+ op.expected_base = str(obs["expected_base"])
894
+ elif t.state is State.FETCHED:
895
+ op.digest = str(obs["digest"])
896
+ op.operation_id = t.operation_id
897
+ elif t.state is State.PLANNED:
898
+ op.observed_base = str(obs["observed_base"])
899
+ op.intended_after = str(obs["intended_after"])
900
+ op.plan_hash = str(obs["plan_hash"])
901
+ op.gate_results = tuple(
902
+ GateResult(**g)
903
+ for g in obs["gate_results"] # type: ignore[arg-type]
904
+ )
905
+ return op
906
+
907
+ # -- the state machine
908
+
909
+ def _drive(self, op: _Op, *, stop_after: State | None = None) -> None:
910
+ last = op.transitions[-1].state if op.transitions else None
911
+ if last is None:
912
+ self._observe(op)
913
+ last = State.OBSERVED
914
+ if last is State.OBSERVED:
915
+ self._fetch(op)
916
+ last = State.FETCHED
917
+ if last is State.FETCHED:
918
+ if not self._plan(op):
919
+ return # refused at plan; journaled
920
+ last = State.PLANNED
921
+ if stop_after is State.PLANNED and last is State.PLANNED:
922
+ return # prepared: gates evaluated and journaled, no verb has run
923
+ if last in (State.PLANNED, State.UNVERIFIABLE):
924
+ if not self._apply(op):
925
+ return # refused or unverifiable; journaled
926
+ last = State.APPLIED
927
+ if last is State.APPLIED:
928
+ if not self._verify(op):
929
+ return # postcondition did not hold; journaled as a note, state stays applied
930
+ last = State.VERIFIED
931
+ if last is State.VERIFIED:
932
+ self._acknowledge(op)
933
+
934
+ def _record(self, op: _Op, state: State, observation: dict[str, object]) -> None:
935
+ transition = self.journal.append(
936
+ pending_id=op.pending_id,
937
+ attempt=op.attempt,
938
+ operation_id=op.operation_id,
939
+ coordinate_key=op.key,
940
+ source_rev=op.source_rev,
941
+ state=state,
942
+ observation=observation,
943
+ )
944
+ op.transitions.append(transition)
945
+ if self.kill_after == state:
946
+ raise SinkKilled(f"killed after {state}")
947
+
948
+ def _note(self, op: _Op, note: str, data: dict[str, object] | None = None) -> None:
949
+ self.journal.note(
950
+ pending_id=op.pending_id,
951
+ attempt=op.attempt,
952
+ coordinate_key=op.key,
953
+ source_rev=op.source_rev,
954
+ note=note,
955
+ data=data,
956
+ )
957
+
958
+ def _observe(self, op: _Op) -> None:
959
+ op.expected_base = self.read_head(op.destination)
960
+ self._record(
961
+ op,
962
+ State.OBSERVED,
963
+ {
964
+ "source_rev": op.source_rev,
965
+ "source_branch": self.branch,
966
+ "previous_cursor": op.previous_cursor,
967
+ "expected_base": op.expected_base,
968
+ "established_by": (
969
+ "ls-remote on the source reported a revision the cursor did not name"
970
+ ),
971
+ },
972
+ )
973
+
974
+ def _fetch(self, op: _Op) -> None:
975
+ if not self.mirror.exists():
976
+ self.mirror.parent.mkdir(parents=True, exist_ok=True)
977
+ subprocess.run(
978
+ ["git", "init", "-q", "--bare", str(self.mirror)],
979
+ check=True,
980
+ capture_output=True,
981
+ text=True,
982
+ env={**os.environ, **self.git_env},
983
+ )
984
+ _git(
985
+ self.mirror,
986
+ "fetch",
987
+ "-q",
988
+ str(self.source_remote),
989
+ f"+refs/heads/{self.branch}:refs/remotes/source/{self.branch}",
990
+ env=self.git_env,
991
+ )
992
+ # presence is asserted against the object itself, not the fetch's exit status
993
+ _git(self.mirror, "cat-file", "-e", f"{op.source_rev}^{{commit}}", env=self.git_env)
994
+ op.digest = tree_digest(self.mirror, op.source_rev, env=self.git_env)
995
+ assert op.expected_base is not None
996
+ op.operation_id = operation_id_for(op.key, op.source_rev, op.expected_base, op.digest)
997
+ self._record(
998
+ op,
999
+ State.FETCHED,
1000
+ {
1001
+ "digest": op.digest,
1002
+ "mirror": str(self.mirror),
1003
+ "established_by": (
1004
+ "cat-file -e on the mirror and the tree id recomputed from the object"
1005
+ ),
1006
+ },
1007
+ )
1008
+
1009
+ def _gate(self, gate_id: str, passed: bool, detail: str) -> GateResult:
1010
+ result = "pass" if passed else "fail"
1011
+ return GateResult(
1012
+ gate_id=gate_id,
1013
+ result=result,
1014
+ detail=detail,
1015
+ result_hash=_sha256_json({"gate": gate_id, "result": result, "detail": detail}),
1016
+ )
1017
+
1018
+ def _gate_not_run(self, gate_id: str, detail: str) -> GateResult:
1019
+ """A gate judged inapplicable to this destination kind, recorded as such.
1020
+
1021
+ ``not-run`` is neither pass nor fail: it does not refuse, and it does not let a
1022
+ reader mistake "the list was empty" for "every gate passed".
1023
+ """
1024
+ return GateResult(
1025
+ gate_id=gate_id,
1026
+ result=_GATE_NOT_RUN,
1027
+ detail=detail,
1028
+ result_hash=_sha256_json({"gate": gate_id, "result": _GATE_NOT_RUN, "detail": detail}),
1029
+ )
1030
+
1031
+ def _plan(self, op: _Op) -> bool:
1032
+ assert op.expected_base is not None and op.digest is not None
1033
+ gates: list[GateResult] = []
1034
+
1035
+ allowed = op.coordinate.direction in self.policy.allowed_directions
1036
+ gates.append(
1037
+ self._gate(
1038
+ "policy.direction",
1039
+ allowed,
1040
+ f"direction={op.coordinate.direction} "
1041
+ f"allowed={sorted(str(d) for d in self.policy.allowed_directions)}",
1042
+ )
1043
+ )
1044
+
1045
+ op.observed_base = self.read_head(op.destination)
1046
+ gates.append(
1047
+ self._gate(
1048
+ "destination.base-unmoved",
1049
+ op.observed_base == op.expected_base,
1050
+ f"expected_base={op.expected_base} observed_base={op.observed_base}",
1051
+ )
1052
+ )
1053
+
1054
+ if op.destination.kind is DestinationKind.CANONICAL:
1055
+ # a bare repository has no worktree; the gate is recorded as NOT RUN rather
1056
+ # than omitted, so a receipt with an empty gate list and one where this
1057
+ # gate was judged inapplicable remain distinguishable (a gate list must say
1058
+ # which gates ran, individually; an aggregate cannot)
1059
+ gates.append(
1060
+ self._gate_not_run(
1061
+ "destination.clean",
1062
+ "bare canonical destination: cleanliness is not a property it has",
1063
+ )
1064
+ )
1065
+ else:
1066
+ porcelain = self._porcelain(op.destination)
1067
+ gates.append(
1068
+ self._gate(
1069
+ "destination.clean",
1070
+ porcelain == "",
1071
+ "worktree and index clean"
1072
+ if porcelain == ""
1073
+ else f"dirty: {len(porcelain.splitlines())} path(s)",
1074
+ )
1075
+ )
1076
+
1077
+ # the destination's branch (canonical) or HEAD (clone) is fetched INTO the
1078
+ # mirror (a read of the destination), so ahead/behind is computed in the
1079
+ # sink's own store
1080
+ observed_tag = hashlib.sha256(op.destination.destination_id.encode()).hexdigest()[:16]
1081
+ observed_ref = f"refs/observed/{observed_tag}"
1082
+ _git(
1083
+ self.mirror,
1084
+ "fetch",
1085
+ "-q",
1086
+ str(op.destination.path),
1087
+ f"+{self._observed_ref_source(op.destination)}:{observed_ref}",
1088
+ env=self.git_env,
1089
+ )
1090
+ counts = _git(
1091
+ self.mirror,
1092
+ "rev-list",
1093
+ "--left-right",
1094
+ "--count",
1095
+ f"{op.source_rev}...{observed_ref}",
1096
+ env=self.git_env,
1097
+ )
1098
+ behind_s, ahead_s = counts.split()
1099
+ behind, ahead = int(behind_s), int(ahead_s)
1100
+ gates.append(
1101
+ self._gate(
1102
+ "destination.fast-forward",
1103
+ ahead == 0,
1104
+ f"ahead={ahead} behind={behind} (destination relative to source revision)",
1105
+ )
1106
+ )
1107
+
1108
+ op.intended_after = op.source_rev
1109
+ op.gate_results = tuple(gates)
1110
+ op.plan_hash = _sha256_json(
1111
+ {
1112
+ "operation_id": op.operation_id,
1113
+ "intended_after": op.intended_after,
1114
+ "policy_hash": self.policy.policy_hash,
1115
+ "gates": [g.gate_id for g in gates],
1116
+ "destination_kind": str(op.destination.kind),
1117
+ }
1118
+ )
1119
+ self._record(
1120
+ op,
1121
+ State.PLANNED,
1122
+ {
1123
+ "observed_base": op.observed_base,
1124
+ "intended_after": op.intended_after,
1125
+ "plan_hash": op.plan_hash,
1126
+ "policy_hash": self.policy.policy_hash,
1127
+ "gate_results": [g.as_dict() for g in gates],
1128
+ "established_by": (
1129
+ "every gate evaluated and recorded individually against the destination "
1130
+ "as read now"
1131
+ ),
1132
+ },
1133
+ )
1134
+ failed = [g for g in gates if g.result == "fail"]
1135
+ if failed:
1136
+ first = failed[0]
1137
+ self._record(
1138
+ op,
1139
+ State.REFUSED,
1140
+ {
1141
+ "refusal_reason": f"{first.gate_id}: {first.detail}",
1142
+ "failed_gates": [g.gate_id for g in failed],
1143
+ "observed_base": op.observed_base,
1144
+ },
1145
+ )
1146
+ return False
1147
+ return True
1148
+
1149
+ def _apply(self, op: _Op) -> bool:
1150
+ assert op.expected_base is not None and op.intended_after is not None
1151
+ try:
1152
+ head = self.read_head(op.destination)
1153
+ except DestinationUnreadable as exc:
1154
+ # nothing has been done to the destination yet, so this is a named refusal
1155
+ self._record(
1156
+ op,
1157
+ State.REFUSED,
1158
+ {"refusal_reason": f"destination.unreadable: {exc}", "observed_base": None},
1159
+ )
1160
+ return False
1161
+
1162
+ if head == op.intended_after:
1163
+ # the verb landed in an earlier life of this sink; do not run it again
1164
+ self._record(
1165
+ op,
1166
+ State.APPLIED,
1167
+ {
1168
+ "after": head,
1169
+ "observed_base": head,
1170
+ "verb_ran_now": False,
1171
+ "established_by": (
1172
+ "read-back found the intended revision already at the destination"
1173
+ ),
1174
+ },
1175
+ )
1176
+ return True
1177
+ if head != op.expected_base:
1178
+ self._record(
1179
+ op,
1180
+ State.REFUSED,
1181
+ {
1182
+ "refusal_reason": (
1183
+ f"expected_base moved: expected {op.expected_base}, observed {head}"
1184
+ ),
1185
+ "observed_base": head,
1186
+ },
1187
+ )
1188
+ return False
1189
+
1190
+ self._note(
1191
+ op, "apply-verb-started", {"expected_base": head, "intended_after": op.intended_after}
1192
+ )
1193
+ if self.before_apply_verb is not None:
1194
+ self.before_apply_verb()
1195
+ if op.destination.kind is DestinationKind.CANONICAL:
1196
+ # Prototype 2 (the contribution protocol): the contribution lands by a push FROM
1197
+ # the mirror (which holds the fetched source objects) TO the owner's bare remote, as a
1198
+ # fast-forward of ``expected_base`` guarded by ``--force-with-lease`` on
1199
+ # that same base. The receiving repository enforces the compare-and-swap:
1200
+ # if its branch is no longer at ``expected_base`` when the push arrives —
1201
+ # including a move inside the window after this process's own head check —
1202
+ # the push is rejected and nothing lands. That rejection is a REFUSAL with
1203
+ # the observed base, not an exception: it is the protocol doing its job.
1204
+ # Why ``--force-with-lease`` can never FORCE here: the plan's
1205
+ # ``destination.fast-forward`` gate already established that ``intended_after``
1206
+ # descends from ``expected_base`` (ahead == 0), so when the lease holds the
1207
+ # update is a genuine fast-forward, and when the owner moved the lease rejects
1208
+ # it before any update. Mutation-checked: forcing the gate to pass lets a stale
1209
+ # contribution overwrite the owner (W2b goes red); dropping the lease for a bare
1210
+ # ``--force`` lets a move inside the check→push window be overwritten (W2d goes
1211
+ # red). The two halves are one mechanism and neither is sufficient alone.
1212
+ lease = f"--force-with-lease=refs/heads/{self.branch}:{op.expected_base}"
1213
+ push = subprocess.run(
1214
+ [
1215
+ "git",
1216
+ "-C",
1217
+ str(self.mirror),
1218
+ "push",
1219
+ "-q",
1220
+ lease,
1221
+ str(op.destination.path),
1222
+ f"{op.intended_after}:refs/heads/{self.branch}",
1223
+ ],
1224
+ capture_output=True,
1225
+ text=True,
1226
+ env={**os.environ, **self.git_env},
1227
+ )
1228
+ if push.returncode != 0:
1229
+ try:
1230
+ moved_to = self.read_head(op.destination)
1231
+ except DestinationUnreadable as exc:
1232
+ self._record(
1233
+ op,
1234
+ State.UNVERIFIABLE,
1235
+ {
1236
+ "detail": (
1237
+ "the lease push returned non-zero and the destination could "
1238
+ f"not be read back: {exc}"
1239
+ ),
1240
+ "observed_base": None,
1241
+ },
1242
+ )
1243
+ return False
1244
+ if moved_to != op.expected_base:
1245
+ self._record(
1246
+ op,
1247
+ State.REFUSED,
1248
+ {
1249
+ "refusal_reason": (
1250
+ f"destination.lease-refused: the owner's branch moved to "
1251
+ f"{moved_to} after the base check; expected {op.expected_base}"
1252
+ ),
1253
+ "observed_base": moved_to,
1254
+ "established_by": (
1255
+ "the receiving repository rejected the lease; read back names "
1256
+ "the revision it holds"
1257
+ ),
1258
+ },
1259
+ )
1260
+ return False
1261
+ raise RuntimeError(
1262
+ "lease push failed although the owner's branch is still at the expected "
1263
+ f"base: {push.stderr.strip()}"
1264
+ )
1265
+ else:
1266
+ _git(
1267
+ op.destination.path,
1268
+ "fetch",
1269
+ "-q",
1270
+ str(self.mirror),
1271
+ f"refs/remotes/source/{self.branch}",
1272
+ env=self.git_env,
1273
+ )
1274
+ _git(
1275
+ op.destination.path, "merge", "-q", "--ff-only", op.intended_after, env=self.git_env
1276
+ )
1277
+ if self.after_apply_verb is not None:
1278
+ self.after_apply_verb()
1279
+ if self.kill_after == KILL_AFTER_APPLY_VERB:
1280
+ raise SinkKilled("killed after the apply verb, before the read back")
1281
+
1282
+ try:
1283
+ after = self.read_head(op.destination)
1284
+ except DestinationUnreadable as exc:
1285
+ self._record(
1286
+ op,
1287
+ State.UNVERIFIABLE,
1288
+ {
1289
+ "detail": (
1290
+ f"the apply verb returned but the destination could not be read back: {exc}"
1291
+ ),
1292
+ "observed_base": None,
1293
+ },
1294
+ )
1295
+ return False
1296
+
1297
+ if after != op.intended_after:
1298
+ self._record(
1299
+ op,
1300
+ State.REFUSED,
1301
+ {
1302
+ "refusal_reason": (
1303
+ f"destination.apply-mismatch: verb returned but destination reports {after}"
1304
+ ),
1305
+ "observed_base": after,
1306
+ },
1307
+ )
1308
+ return False
1309
+
1310
+ self._record(
1311
+ op,
1312
+ State.APPLIED,
1313
+ {
1314
+ "after": after,
1315
+ "observed_base": head,
1316
+ "verb_ran_now": True,
1317
+ "established_by": (
1318
+ "rev-parse HEAD on the destination after the verb reports the intended revision"
1319
+ ),
1320
+ },
1321
+ )
1322
+ return True
1323
+
1324
+ def _verify(self, op: _Op) -> bool:
1325
+ assert op.intended_after is not None and op.digest is not None
1326
+ head = self.read_head(op.destination)
1327
+ tree = tree_digest(op.destination.path, head, env=self.git_env)
1328
+ if op.destination.kind is DestinationKind.CANONICAL:
1329
+ postcondition = _POSTCONDITION_CANONICAL
1330
+ holds = head == op.intended_after and tree == op.digest
1331
+ detail = f"branch={head} tree={tree}"
1332
+ else:
1333
+ postcondition = _POSTCONDITION
1334
+ porcelain = self._porcelain(op.destination)
1335
+ holds = head == op.intended_after and tree == op.digest and porcelain == ""
1336
+ detail = f"head={head} tree={tree} clean={porcelain == ''}"
1337
+ if not holds:
1338
+ self._note(
1339
+ op, "postcondition-failed", {"postcondition": postcondition, "detail": detail}
1340
+ )
1341
+ return False
1342
+ self._record(
1343
+ op,
1344
+ State.VERIFIED,
1345
+ {
1346
+ "postcondition_checked": postcondition,
1347
+ "holds": True,
1348
+ "detail": detail,
1349
+ "established_by": "the named postcondition re-read from the destination",
1350
+ },
1351
+ )
1352
+ return True
1353
+
1354
+ def _acknowledge(self, op: _Op) -> None:
1355
+ # the journal row is the acknowledgment; the cursor is derived from it and
1356
+ # repaired from it on replay, so the row is written first
1357
+ self._record(
1358
+ op,
1359
+ State.ACKNOWLEDGED,
1360
+ {"cursor": op.source_rev, "established_by": "verified was recorded for this operation"},
1361
+ )
1362
+ self.journal.advance_cursor(op.key, op.source_rev, op.pending_id)
1363
+
1364
+ # -- receipts
1365
+
1366
+ def _receipt(
1367
+ self, coordinate: Coordinate, source_rev: str, attempt: int, *, replayed: bool
1368
+ ) -> Receipt:
1369
+ key = coordinate.key()
1370
+ # rows are read in journal order: a note and a later transition about the same
1371
+ # fact resolve to whichever came last, never to whichever kind is handled last
1372
+ rows = [r for r in self.journal.rows_for(key, source_rev) if int(r["attempt"]) == attempt]
1373
+ transitions: list[Transition] = []
1374
+
1375
+ expected_base = ""
1376
+ observed_base: str | None = None
1377
+ after: str | None = None
1378
+ digest: str | None = None
1379
+ operation_id: str | None = None
1380
+ plan_hash: str | None = None
1381
+ gate_results: tuple[GateResult, ...] = ()
1382
+ postcondition_checked: str | None = None
1383
+ postcondition_holds: bool | None = None
1384
+ refusal_reason: str | None = None
1385
+ detail = ""
1386
+
1387
+ for row in rows:
1388
+ if row.get("kind") == "note":
1389
+ if row.get("note") == "postcondition-failed":
1390
+ data = row.get("data") or {}
1391
+ postcondition_checked = str(data.get("postcondition", _POSTCONDITION)) # type: ignore[union-attr]
1392
+ postcondition_holds = False
1393
+ detail = str(data.get("detail", "")) # type: ignore[union-attr]
1394
+ continue
1395
+ t = Transition(
1396
+ state=State(str(row["state"])),
1397
+ observation=dict(row["observation"]), # type: ignore[arg-type]
1398
+ timestamp=str(row["timestamp"]),
1399
+ operation_id=row.get("operation_id"), # type: ignore[arg-type]
1400
+ )
1401
+ transitions.append(t)
1402
+ obs = t.observation
1403
+ if t.state is State.OBSERVED:
1404
+ expected_base = str(obs["expected_base"])
1405
+ elif t.state is State.FETCHED:
1406
+ digest = str(obs["digest"])
1407
+ operation_id = t.operation_id
1408
+ elif t.state is State.PLANNED:
1409
+ observed_base = str(obs["observed_base"])
1410
+ plan_hash = str(obs["plan_hash"])
1411
+ gate_results = tuple(GateResult(**g) for g in obs["gate_results"]) # type: ignore[arg-type]
1412
+ elif t.state is State.APPLIED:
1413
+ after = str(obs["after"])
1414
+ observed_base = str(obs["observed_base"])
1415
+ elif t.state is State.VERIFIED:
1416
+ postcondition_checked = str(obs["postcondition_checked"])
1417
+ postcondition_holds = True
1418
+ detail = str(obs.get("detail", ""))
1419
+ elif t.state is State.REFUSED:
1420
+ refusal_reason = str(obs["refusal_reason"])
1421
+ if obs.get("observed_base") is not None:
1422
+ observed_base = str(obs["observed_base"])
1423
+ elif t.state is State.UNVERIFIABLE:
1424
+ detail = str(obs["detail"])
1425
+
1426
+ if not transitions:
1427
+ raise LookupError(
1428
+ f"no transitions journaled for attempt {attempt} of {key} @ {source_rev}"
1429
+ )
1430
+ state = transitions[-1].state
1431
+ return Receipt(
1432
+ pending_id=pending_id_for(key, source_rev, attempt),
1433
+ attempt=attempt,
1434
+ operation_id=operation_id,
1435
+ coordinate=coordinate,
1436
+ source_rev=source_rev,
1437
+ expected_base=expected_base,
1438
+ observed_base=observed_base,
1439
+ after=after,
1440
+ digest=digest,
1441
+ plan_hash=plan_hash,
1442
+ policy_hash=self.policy.policy_hash,
1443
+ gate_results=gate_results,
1444
+ state=state,
1445
+ postcondition_checked=postcondition_checked,
1446
+ postcondition_holds=postcondition_holds,
1447
+ timestamp=transitions[-1].timestamp,
1448
+ refusal_reason=refusal_reason,
1449
+ detail=detail,
1450
+ transitions=tuple(transitions),
1451
+ replayed=replayed,
1452
+ )
1453
+
1454
+
1455
+ __all__ = [
1456
+ "KILL_AFTER_APPLY_VERB",
1457
+ "Coordinate",
1458
+ "Destination",
1459
+ "DestinationKind",
1460
+ "DestinationUnreadable",
1461
+ "JournalInconsistent",
1462
+ "MalformedLine",
1463
+ "Direction",
1464
+ "GateResult",
1465
+ "Journal",
1466
+ "Observation",
1467
+ "Operation",
1468
+ "PlanOutcome",
1469
+ "Policy",
1470
+ "Propagator",
1471
+ "Receipt",
1472
+ "SinkKilled",
1473
+ "State",
1474
+ "Transition",
1475
+ "operation_id_for",
1476
+ "pending_id_for",
1477
+ "tree_digest",
1478
+ ]