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,665 @@
1
+ """Prototype 2: the contribution protocol around the propagation state machine.
2
+
3
+ The machine (``propagation_state_machine``) lands ONE contribution on a canonical
4
+ remote by compare-and-swap. This module is the protocol that decides WHICH owner a
5
+ change is proposed to, lands SEVERAL contributions that must go together, refuses
6
+ to RETIRE a workspace that still holds open contributions, and gives declared
7
+ append-only surfaces the one behaviour they are allowed that everything else is
8
+ not: two writers, both land, neither replans.
9
+
10
+ Everything here is neutral: layer refs, destination ids, and surface names are
11
+ opaque strings the caller resolves. The module holds no notion of who an agent or
12
+ an org is, and who MAY contribute where is a policy it is handed, never derives.
13
+
14
+ Four pieces, each the smallest shape that its witness needs:
15
+
16
+ ``ResolvedManifest`` / ``ResolvedEntry``
17
+ Ownership is a RECORDED fact, never a guess over path shape. Every resolved
18
+ entry carries ``declared_by`` (the layer whose declaration put it in the
19
+ workspace), ``overridden_by`` (the layer whose override currently governs it,
20
+ if any) and ``write_mode`` (``own`` / ``contribute`` / ``append`` / ``read``).
21
+ ``owner()`` is ``overridden_by or declared_by``; ``classify()`` answers, for a
22
+ path, whether a change is local authoring, a contribution (and to whom), an
23
+ append, or refused. Two layers declaring the same entry without an override is
24
+ a NAMED collision at resolution, not a silent precedence.
25
+
26
+ Today's resolver FLATTENS this information away (it merges repos by name and
27
+ records no provenance), so this dataclass is the specification of the field
28
+ the resolver must grow, and the witnesses run against the stub.
29
+
30
+ ``ContributionSet``
31
+ Several contributions that must land together land in DECLARED ORDER with one
32
+ receipt per member and one set receipt naming what landed — all-or-REPORT,
33
+ not all-or-nothing. ``prepare()`` drives every member through its gates and
34
+ stops before any verb (``stop_after=PLANNED``); if any member refuses there,
35
+ nothing has landed and the set reports it. ``land()`` then resumes each member
36
+ in order and STOPS at the first member that does not reach acknowledged,
37
+ leaving earlier members landed (git history is forward-only; a rollback would
38
+ be a new forward operation, never an un-push) and naming the landed, the
39
+ refusing, and the not-attempted members in the set receipt.
40
+
41
+ ``Subspace``
42
+ A workspace that holds contributions. ``retire()`` REFUSES while any of its
43
+ contributions is not terminal — not acknowledged and not explicitly
44
+ abandoned — and lists their operation ids. ``abandon()`` writes an
45
+ ``abandoned`` note to the contribution's own journal, so a workspace can never
46
+ be retired with a change that simply evaporates.
47
+
48
+ ``AppendSurface``
49
+ A declared append-only file: one guarded append point (an exclusive lock held
50
+ across write + flush + fsync), each record numbered in arrival order. Two
51
+ writers both land; no expected base exists because the operation commutes.
52
+ This is commutative BY DECLARATION, never inferred from the shape of a change,
53
+ and it is a file-level surface: a git-tracked path is never an append surface
54
+ to this protocol, because landing two appends there would require merging on
55
+ an author's behalf.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import fcntl
61
+ import json
62
+ import os
63
+ from collections.abc import Callable, Iterable, Sequence
64
+ from dataclasses import asdict, dataclass, field
65
+ from datetime import UTC, datetime
66
+ from enum import StrEnum
67
+ from pathlib import Path
68
+
69
+ from gr2.prototypes.propagation_state_machine import (
70
+ Coordinate,
71
+ Destination,
72
+ MalformedLine,
73
+ Propagator,
74
+ Receipt,
75
+ State,
76
+ )
77
+
78
+ ABANDONED_NOTE = "abandoned"
79
+
80
+
81
+ class WriteMode(StrEnum):
82
+ OWN = "own"
83
+ CONTRIBUTE = "contribute"
84
+ APPEND = "append"
85
+ READ = "read"
86
+
87
+
88
+ class ResolutionCollision(ValueError):
89
+ """Two layers declared the same entry and neither said ``override``.
90
+
91
+ Named and refused at resolution; never resolved by precedence silently.
92
+ """
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class ResolvedEntry:
97
+ """One surface of a materialized workspace, with its provenance recorded."""
98
+
99
+ name: str
100
+ path: str
101
+ declared_by: str
102
+ overridden_by: str | None
103
+ write_mode: WriteMode
104
+
105
+ @property
106
+ def owner(self) -> str:
107
+ return self.overridden_by or self.declared_by
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Classification:
112
+ """What a change to ``path`` is, under this workspace's recorded ownership."""
113
+
114
+ entry: ResolvedEntry | None
115
+ mode: WriteMode
116
+ owner: str | None
117
+ reason: str
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class Declaration:
122
+ """One layer's declaration of one entry, as the resolver sees it before merging."""
123
+
124
+ layer: str
125
+ name: str
126
+ path: str
127
+ overrides: bool = False
128
+
129
+
130
+ class ResolvedManifest:
131
+ """Ownership as a lookup. Built by ``resolve``; read by ``classify``."""
132
+
133
+ def __init__(self, entries: Iterable[ResolvedEntry]) -> None:
134
+ self._entries = {e.name: e for e in entries}
135
+
136
+ @classmethod
137
+ def resolve(
138
+ cls, this_layer: str, declarations: Sequence[Declaration], *, appends: Iterable[str] = ()
139
+ ) -> ResolvedManifest:
140
+ """Merge layer declarations (ancestors first) with provenance kept.
141
+
142
+ A later declaration of a name already declared is a ``ResolutionCollision``
143
+ unless it says ``overrides``; an override records ``overridden_by`` and keeps
144
+ ``declared_by``. Names in ``appends`` are declared append-only by their owner.
145
+ """
146
+ merged: dict[str, ResolvedEntry] = {}
147
+ append_names = set(appends)
148
+ for d in declarations:
149
+ prior = merged.get(d.name)
150
+ if prior is None:
151
+ merged[d.name] = ResolvedEntry(
152
+ name=d.name,
153
+ path=d.path,
154
+ declared_by=d.layer,
155
+ overridden_by=None,
156
+ write_mode=WriteMode.READ,
157
+ )
158
+ continue
159
+ if not d.overrides:
160
+ raise ResolutionCollision(
161
+ f"{d.name!r} declared by {prior.declared_by!r} at {prior.path!r} and again by "
162
+ f"{d.layer!r} at {d.path!r} without override"
163
+ )
164
+ merged[d.name] = ResolvedEntry(
165
+ name=d.name,
166
+ path=d.path,
167
+ declared_by=prior.declared_by,
168
+ overridden_by=d.layer,
169
+ write_mode=WriteMode.READ,
170
+ )
171
+ entries = []
172
+ for e in merged.values():
173
+ if e.name in append_names:
174
+ mode = WriteMode.APPEND
175
+ elif e.owner == this_layer:
176
+ mode = WriteMode.OWN
177
+ else:
178
+ mode = WriteMode.CONTRIBUTE
179
+ entries.append(
180
+ ResolvedEntry(
181
+ name=e.name,
182
+ path=e.path,
183
+ declared_by=e.declared_by,
184
+ overridden_by=e.overridden_by,
185
+ write_mode=mode,
186
+ )
187
+ )
188
+ return cls(entries)
189
+
190
+ def entry(self, name: str) -> ResolvedEntry:
191
+ return self._entries[name]
192
+
193
+ def owner(self, name: str) -> str:
194
+ return self._entries[name].owner
195
+
196
+ def classify(self, path: str) -> Classification:
197
+ """The longest declared path prefix wins; a path under no entry is refused (READ)."""
198
+ best: ResolvedEntry | None = None
199
+ for e in self._entries.values():
200
+ root = e.path.rstrip("/") + "/"
201
+ if (path == e.path or path.startswith(root)) and (
202
+ best is None or len(e.path) > len(best.path)
203
+ ):
204
+ best = e
205
+ if best is None:
206
+ return Classification(
207
+ entry=None,
208
+ mode=WriteMode.READ,
209
+ owner=None,
210
+ reason=f"{path!r} is under no declared entry",
211
+ )
212
+ return Classification(
213
+ entry=best,
214
+ mode=best.write_mode,
215
+ owner=best.owner,
216
+ reason=f"{path!r} is under {best.name!r} declared_by={best.declared_by!r} "
217
+ f"overridden_by={best.overridden_by!r}",
218
+ )
219
+
220
+
221
+ # --------------------------------------------------------------------------- sets
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class SetMember:
226
+ member_id: str
227
+ coordinate: Coordinate
228
+ destination: Destination
229
+ propagator: Propagator
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class MemberOutcome:
234
+ member_id: str
235
+ state: str # a State value, or "not-attempted"
236
+ receipt: Receipt | None
237
+
238
+ def as_dict(self) -> dict[str, object]:
239
+ return {
240
+ "member_id": self.member_id,
241
+ "state": self.state,
242
+ "receipt": self.receipt.as_dict() if self.receipt is not None else None,
243
+ }
244
+
245
+
246
+ @dataclass(frozen=True)
247
+ class SetReceipt:
248
+ set_id: str
249
+ phase: str # "prepared" | "refused-at-prepare" | "landed" | "stopped"
250
+ order: tuple[str, ...]
251
+ outcomes: tuple[MemberOutcome, ...]
252
+ timestamp: str
253
+
254
+ @property
255
+ def landed(self) -> tuple[str, ...]:
256
+ return tuple(o.member_id for o in self.outcomes if o.state == str(State.ACKNOWLEDGED))
257
+
258
+ @property
259
+ def refused(self) -> tuple[str, ...]:
260
+ return tuple(
261
+ o.member_id
262
+ for o in self.outcomes
263
+ if o.state in (str(State.REFUSED), str(State.UNVERIFIABLE))
264
+ )
265
+
266
+ @property
267
+ def not_attempted(self) -> tuple[str, ...]:
268
+ return tuple(o.member_id for o in self.outcomes if o.state == "not-attempted")
269
+
270
+ def as_dict(self) -> dict[str, object]:
271
+ return {
272
+ "set_id": self.set_id,
273
+ "phase": self.phase,
274
+ "order": list(self.order),
275
+ "landed": list(self.landed),
276
+ "refused": list(self.refused),
277
+ "not_attempted": list(self.not_attempted),
278
+ "outcomes": [o.as_dict() for o in self.outcomes],
279
+ "timestamp": self.timestamp,
280
+ }
281
+
282
+
283
+ def _now() -> str:
284
+ return datetime.now(UTC).isoformat(timespec="microseconds")
285
+
286
+
287
+ def state_latencies(receipt: Receipt) -> list[tuple[str, float]]:
288
+ """Per-state latency from the receipt's OWN timestamps, in seconds.
289
+
290
+ Each row is ``(state, seconds since the previous transition)``; the first row is
291
+ ``(state, 0.0)``. A measurement, not a witness: the receipts already carry the
292
+ timestamps, so the number comes from the artifact and not from a stopwatch around
293
+ it. Printed so a daemon's ``up`` budget can start from data.
294
+ """
295
+ rows: list[tuple[str, float]] = []
296
+ previous: datetime | None = None
297
+ for t in receipt.transitions:
298
+ at = datetime.fromisoformat(t.timestamp)
299
+ rows.append((str(t.state), 0.0 if previous is None else (at - previous).total_seconds()))
300
+ previous = at
301
+ return rows
302
+
303
+
304
+ class ContributionSet:
305
+ """Several contributions that must land together: prepare all, land in order, report."""
306
+
307
+ def __init__(self, set_id: str, members: Sequence[SetMember]) -> None:
308
+ if not members:
309
+ raise ValueError("a contribution set needs at least one member")
310
+ ids = [m.member_id for m in members]
311
+ if len(set(ids)) != len(ids):
312
+ raise ValueError(f"duplicate member ids in set {set_id!r}: {ids}")
313
+ self.set_id = set_id
314
+ self.members = tuple(members)
315
+
316
+ @property
317
+ def order(self) -> tuple[str, ...]:
318
+ return tuple(m.member_id for m in self.members)
319
+
320
+ def prepare(self) -> SetReceipt:
321
+ """Drive every member to ``planned`` without running a verb.
322
+
323
+ If any member refuses at plan, the set is refused BEFORE anything lands and the
324
+ receipt carries every member's evidence. A member whose source has nothing new
325
+ (``run`` returned ``None``) is reported as not-attempted: it is not part of this
326
+ set's landing.
327
+ """
328
+ outcomes: list[MemberOutcome] = []
329
+ for m in self.members:
330
+ receipt = m.propagator.run(m.coordinate, m.destination, stop_after=State.PLANNED)
331
+ if receipt is None:
332
+ outcomes.append(MemberOutcome(m.member_id, "not-attempted", None))
333
+ else:
334
+ outcomes.append(MemberOutcome(m.member_id, str(receipt.state), receipt))
335
+ any_refused = any(o.state == str(State.REFUSED) for o in outcomes)
336
+ return SetReceipt(
337
+ set_id=self.set_id,
338
+ phase="refused-at-prepare" if any_refused else "prepared",
339
+ order=self.order,
340
+ outcomes=tuple(outcomes),
341
+ timestamp=_now(),
342
+ )
343
+
344
+ def land(self, *, on_member_landed: Callable[[str], None] | None = None) -> SetReceipt:
345
+ """Resume each prepared member in order; STOP at the first that does not acknowledge.
346
+
347
+ Earlier members stay landed. The receipt names landed / refused / not-attempted
348
+ members so the author can resolve forward. Nothing is rolled back.
349
+ """
350
+ outcomes: list[MemberOutcome] = []
351
+ stopped = False
352
+ for m in self.members:
353
+ if stopped:
354
+ outcomes.append(MemberOutcome(m.member_id, "not-attempted", None))
355
+ continue
356
+ receipt = m.propagator.run(m.coordinate, m.destination)
357
+ if receipt is None:
358
+ outcomes.append(MemberOutcome(m.member_id, "not-attempted", None))
359
+ continue
360
+ outcomes.append(MemberOutcome(m.member_id, str(receipt.state), receipt))
361
+ if receipt.state is State.ACKNOWLEDGED:
362
+ if on_member_landed is not None:
363
+ on_member_landed(m.member_id)
364
+ else:
365
+ stopped = True
366
+ return SetReceipt(
367
+ set_id=self.set_id,
368
+ phase="stopped" if stopped else "landed",
369
+ order=self.order,
370
+ outcomes=tuple(outcomes),
371
+ timestamp=_now(),
372
+ )
373
+
374
+
375
+ # --------------------------------------------------------------------------- subspaces
376
+
377
+
378
+ @dataclass(frozen=True)
379
+ class OpenContribution:
380
+ coordinate_key: str
381
+ source_rev: str
382
+ last_state: str
383
+ operation_id: str | None
384
+
385
+
386
+ class RetireRefused(RuntimeError):
387
+ """The subspace holds contributions that are neither acknowledged nor abandoned."""
388
+
389
+ def __init__(self, subspace: str, open_contributions: Sequence[OpenContribution]) -> None:
390
+ self.subspace = subspace
391
+ self.open_contributions = tuple(open_contributions)
392
+ listed = ", ".join(
393
+ f"{o.coordinate_key} @ {o.source_rev[:12]} ({o.last_state})" for o in open_contributions
394
+ )
395
+ super().__init__(f"{subspace}: {len(open_contributions)} open contribution(s): {listed}")
396
+
397
+
398
+ @dataclass
399
+ class Subspace:
400
+ """A workspace and the contributions authored in it, each carried by its own sink."""
401
+
402
+ name: str
403
+ contributions: list[tuple[Coordinate, Propagator]] = field(default_factory=list)
404
+ retired: bool = False
405
+
406
+ def open_contributions(self) -> list[OpenContribution]:
407
+ """Every (coordinate, source revision) attempt whose last state is not terminal.
408
+
409
+ Terminal means ACKNOWLEDGED, or REFUSED-and-then-ABANDONED by an explicit note.
410
+ A refused attempt with no abandon note is OPEN: the refusal described a moment and
411
+ the author has not said what becomes of the change.
412
+ """
413
+ found: list[OpenContribution] = []
414
+ for coordinate, propagator in self.contributions:
415
+ key = coordinate.key()
416
+ revs: dict[str, list[dict[str, object]]] = {}
417
+ for row in propagator.journal._rows():
418
+ if row.get("coordinate_key") != key:
419
+ continue
420
+ revs.setdefault(str(row["source_rev"]), []).append(row)
421
+ for source_rev, rows in revs.items():
422
+ states = [str(r["state"]) for r in rows if "state" in r]
423
+ last_state = states[-1] if states else "(no transition)"
424
+ if last_state == str(State.ACKNOWLEDGED):
425
+ continue
426
+ if any(r.get("note") == ABANDONED_NOTE for r in rows):
427
+ continue
428
+ op_ids = [str(r["operation_id"]) for r in rows if r.get("operation_id")]
429
+ found.append(
430
+ OpenContribution(
431
+ coordinate_key=key,
432
+ source_rev=source_rev,
433
+ last_state=last_state,
434
+ operation_id=op_ids[-1] if op_ids else None,
435
+ )
436
+ )
437
+ return found
438
+
439
+ def abandon(self, coordinate: Coordinate, source_rev: str, *, reason: str) -> None:
440
+ """Explicitly give up a contribution: an ``abandoned`` note in its own journal."""
441
+ for c, propagator in self.contributions:
442
+ if c.key() != coordinate.key():
443
+ continue
444
+ attempts = propagator.journal.find(coordinate.key(), source_rev)
445
+ attempt = len(attempts) if attempts else 1
446
+ pending_id = f"{coordinate.key()}@{source_rev}"
447
+ if attempts and attempts[-1][-1].operation_id:
448
+ pending_id = str(attempts[-1][-1].operation_id)
449
+ propagator.journal.note(
450
+ pending_id=pending_id,
451
+ attempt=attempt,
452
+ coordinate_key=coordinate.key(),
453
+ source_rev=source_rev,
454
+ note=ABANDONED_NOTE,
455
+ data={"reason": reason},
456
+ )
457
+ return
458
+ raise KeyError(f"{self.name}: no contribution at {coordinate.key()}")
459
+
460
+ def retire(self) -> None:
461
+ """Refuse while any contribution is open; otherwise mark retired."""
462
+ open_ = self.open_contributions()
463
+ if open_:
464
+ raise RetireRefused(self.name, open_)
465
+ self.retired = True
466
+
467
+
468
+ # --------------------------------------------------------------------------- append surfaces
469
+
470
+
471
+ @dataclass(frozen=True)
472
+ class AppendRecord:
473
+ seq: int
474
+ writer: str
475
+ payload: dict[str, object]
476
+ timestamp: str
477
+
478
+
479
+ def _decode_line(raw: bytes) -> tuple[str | None, str]:
480
+ """Bytes become text HERE, so the failure that can happen HERE is guarded here.
481
+
482
+ A JSONL file is a BYTE format. Read in text mode, the decode happens while the
483
+ iterator produces the line — outside every ``try`` in this module — so one
484
+ invalid byte anywhere raises before a guard can see it and the whole scan dies.
485
+ The exception type was never the problem: ``UnicodeDecodeError`` subclasses
486
+ ``ValueError``, which was already caught. The OPERATION that raises it simply sat
487
+ outside the guarded region. A line does not ARRIVE as text, it is MANUFACTURED as
488
+ text, and manufacturing it can fail on content; decoding per line confines a bad
489
+ byte to that line.
490
+ """
491
+
492
+ try:
493
+ return raw.decode("utf-8"), ""
494
+ except UnicodeDecodeError as exc:
495
+ return None, str(exc)
496
+
497
+
498
+ def _excerpt(raw: bytes) -> str:
499
+ """A malformed line must be reportable even when it is not valid text."""
500
+
501
+ return raw[:120].decode("utf-8", "replace")
502
+
503
+
504
+ def _read_json_object(line: str) -> tuple[dict[str, object] | None, str]:
505
+ """Parse ONE line into a JSON object, or say why it cannot be read.
506
+
507
+ ``json.loads`` is the ONLY operation in this file that can raise on file
508
+ content. It raises ``ValueError`` (``JSONDecodeError`` subclasses it, as does
509
+ the integer-literal length limit) or ``RecursionError`` (deeply nested input).
510
+ Everything after it VALIDATES rather than coerces, and that is the whole point:
511
+ a coercion over untrusted bytes forces the caller to enumerate the exceptions it
512
+ might raise, which is a denylist, and a denylist leaks. ``int()`` on a value
513
+ ``json`` can legitimately produce (``1e999`` parses to ``inf``) raises
514
+ ``OverflowError`` — a type no list written from the parse side would predict.
515
+ Validating instead removes the raise rather than cataloguing it.
516
+ """
517
+
518
+ try:
519
+ obj = json.loads(line)
520
+ except (ValueError, RecursionError) as exc:
521
+ return None, str(exc)
522
+ if not isinstance(obj, dict):
523
+ return None, f"line is a {type(obj).__name__}, not an object"
524
+ return obj, ""
525
+
526
+
527
+ def _read_seq(obj: dict[str, object]) -> tuple[int | None, str]:
528
+ """A sequence number must be a real integer. ``bool`` is an ``int`` and is not one."""
529
+
530
+ seq = obj.get("seq")
531
+ if isinstance(seq, bool) or not isinstance(seq, int):
532
+ return None, f"seq is {type(seq).__name__}, not an integer"
533
+ return seq, ""
534
+
535
+
536
+ def _read_record(obj: dict[str, object]) -> tuple[AppendRecord | None, str]:
537
+ """Every field checked by TYPE, so nothing here can raise on hostile content."""
538
+
539
+ seq, reason = _read_seq(obj)
540
+ if seq is None:
541
+ return None, reason
542
+ writer = obj.get("writer")
543
+ payload = obj.get("payload")
544
+ timestamp = obj.get("timestamp")
545
+ if not isinstance(writer, str):
546
+ return None, f"writer is {type(writer).__name__}, not a string"
547
+ if not isinstance(payload, dict):
548
+ return None, f"payload is {type(payload).__name__}, not an object"
549
+ if not isinstance(timestamp, str):
550
+ return None, f"timestamp is {type(timestamp).__name__}, not a string"
551
+ return AppendRecord(seq=seq, writer=writer, payload=payload, timestamp=timestamp), ""
552
+
553
+
554
+ class AppendSurface:
555
+ """A declared append-only file with one guarded append point.
556
+
557
+ ``append`` takes an exclusive lock on the file, reads the last sequence number,
558
+ writes ``seq + 1`` with the record, flushes, fsyncs, and releases. Two writers
559
+ interleave in ARRIVAL order and both land; there is no expected base to refuse on
560
+ because appends commute. Nothing here ever rewrites an earlier record.
561
+
562
+ A writer killed between ``write`` and its terminator leaves a remnant line. Both
563
+ scans SKIP such a line and COUNT it (``malformed_lines``, reflecting the most
564
+ recent full scan): an uncounted drop is silent, and this surface is the one place
565
+ a caller looks. ``append`` also repairs a missing terminator before writing, so a
566
+ remnant never GLUES the next record onto itself — without that repair the record
567
+ that follows a torn write is swallowed into an unparseable line and lost.
568
+
569
+ The file is read as BYTES and decoded one line at a time. A JSONL file is a byte
570
+ format, and in text mode the decode happens while the iterator produces the line,
571
+ outside every guard here — so a single invalid byte anywhere destroyed the whole
572
+ scan, including every record written correctly around it. A line passes through
573
+ four layers: read, decode, parse, and shape-check. The last three are each guarded
574
+ where they happen. Unbounded line length (a file with no terminator for a very
575
+ long span) is a resource limit on the first layer and is NOT defended here.
576
+ """
577
+
578
+ def __init__(self, path: Path) -> None:
579
+ self.path = path
580
+ self.path.parent.mkdir(parents=True, exist_ok=True)
581
+ self.path.touch(exist_ok=True)
582
+ self._malformed: tuple[MalformedLine, ...] = ()
583
+
584
+ @property
585
+ def malformed_lines(self) -> tuple[MalformedLine, ...]:
586
+ """Lines the most recent full scan could not read. Empty is the healthy case."""
587
+ return self._malformed
588
+
589
+ def append(self, writer: str, payload: dict[str, object]) -> AppendRecord:
590
+ with open(self.path, "a+b") as handle:
591
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
592
+ try:
593
+ handle.seek(0)
594
+ last = 0
595
+ malformed: list[MalformedLine] = []
596
+ raw_last = b""
597
+ for index, raw in enumerate(handle):
598
+ raw_last = raw
599
+ stripped = raw.strip()
600
+ if not stripped:
601
+ continue
602
+ line, reason = _decode_line(stripped)
603
+ if line is not None:
604
+ obj, reason = _read_json_object(line)
605
+ if obj is not None:
606
+ seq, reason = _read_seq(obj)
607
+ if seq is not None:
608
+ last = max(last, seq)
609
+ continue
610
+ malformed.append(
611
+ MalformedLine(index=index, excerpt=_excerpt(stripped), reason=reason)
612
+ )
613
+ self._malformed = tuple(malformed)
614
+ record = AppendRecord(
615
+ seq=last + 1, writer=writer, payload=payload, timestamp=_now()
616
+ )
617
+ handle.seek(0, os.SEEK_END)
618
+ if raw_last and not raw_last.endswith(b"\n"):
619
+ handle.write(b"\n")
620
+ handle.write((json.dumps(asdict(record), sort_keys=True) + "\n").encode("utf-8"))
621
+ handle.flush()
622
+ os.fsync(handle.fileno())
623
+ return record
624
+ finally:
625
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
626
+
627
+ def records(self) -> list[AppendRecord]:
628
+ out: list[AppendRecord] = []
629
+ malformed: list[MalformedLine] = []
630
+ for index, raw in enumerate(self.path.read_bytes().split(b"\n")):
631
+ stripped = raw.strip()
632
+ if not stripped:
633
+ continue
634
+ line, reason = _decode_line(stripped)
635
+ if line is not None:
636
+ obj, reason = _read_json_object(line)
637
+ if obj is not None:
638
+ record, reason = _read_record(obj)
639
+ if record is not None:
640
+ out.append(record)
641
+ continue
642
+ malformed.append(MalformedLine(index=index, excerpt=_excerpt(stripped), reason=reason))
643
+ self._malformed = tuple(malformed)
644
+ return out
645
+
646
+
647
+ __all__ = [
648
+ "ABANDONED_NOTE",
649
+ "AppendRecord",
650
+ "AppendSurface",
651
+ "Classification",
652
+ "ContributionSet",
653
+ "Declaration",
654
+ "MemberOutcome",
655
+ "OpenContribution",
656
+ "ResolutionCollision",
657
+ "ResolvedEntry",
658
+ "ResolvedManifest",
659
+ "RetireRefused",
660
+ "SetMember",
661
+ "SetReceipt",
662
+ "Subspace",
663
+ "WriteMode",
664
+ "state_latencies",
665
+ ]