feat(coordination): git backend wiring + verbatim log migration (WP-0009 T4)

InformationSpace.git_backed(space_id, repo_path) wires the git coordination log;
the default constructor stays in-memory for tests (new keyword-only store=). A
one-time importer (migrate_space / import_log / JSONL export+import) replays an
existing in-memory or JSON log into git verbatim — preserving seq, timestamp and
actor (union-without-erasure) and refusing out-of-order import. Same fold after
migration; no behavioural change to overlay/union. SCOPE updated; WP-0009 done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-06-16 01:49:55 +02:00
parent f0fee65cc0
commit c731c96634
7 changed files with 186 additions and 8 deletions

View file

@ -17,6 +17,12 @@ from shard_wiki.coordination.append_authority import (
LeaseRegistry,
)
from shard_wiki.coordination.git_event_store import GitEventStore
from shard_wiki.coordination.migration import (
export_jsonl,
import_jsonl,
import_log,
migrate_space,
)
from shard_wiki.coordination.overlay import (
ApplyResult,
ApplyStatus,
@ -37,6 +43,10 @@ __all__ = [
"LeaseHeld",
"LeaseRegistry",
"AppendAuthority",
"import_log",
"migrate_space",
"export_jsonl",
"import_jsonl",
"serialize_event",
"deserialize_event",
"Overlay",

View file

@ -75,6 +75,24 @@ class GitEventStore:
return event
raise RuntimeError(f"append contention on {space!r}: exhausted {_MAX_CAS_RETRIES} retries")
def import_event(self, event: DecisionEvent) -> None:
"""Replay one pre-existing event *verbatim* (preserving seq / timestamp / actor) onto its
space ref the one-time migration path (SHARD-WP-0009 T4), not a live append.
Refuses out-of-order import so the imported chain stays a contiguous total order; preserving
the original fields keeps provenance intact (union-without-erasure) rather than restamping.
"""
ref = self._ref(event.space)
head = self._head(ref)
expected = self._count(ref, head)
if event.seq != expected:
raise ValueError(
f"out-of-order import on {event.space!r}: expected seq {expected}, got {event.seq}"
)
commit = self._commit_event(event, parent=head)
if not self._cas_update(ref, new=commit, old=head):
raise RuntimeError(f"import race on {ref}")
def events(self, space: str) -> tuple[DecisionEvent, ...]:
"""The space's events oldest→newest (append/total order)."""
ref = self._ref(space)

View file

@ -0,0 +1,53 @@
"""One-time migration of a coordination log into git (SHARD-WP-0009 T4).
Replays an existing decision log an in-memory store, or a JSON-lines export into a
:class:`GitEventStore`, preserving each event verbatim (seq / timestamp / actor) so provenance
survives the move (union-without-erasure). After migration the same :meth:`DecisionLog.fold`
reproduces identical coordination state; only durability changes.
"""
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from shard_wiki.coordination.decision_log import (
DecisionEvent,
EventStore,
deserialize_event,
serialize_event,
)
from shard_wiki.coordination.git_event_store import GitEventStore
__all__ = ["import_log", "migrate_space", "export_jsonl", "import_jsonl"]
def import_log(events: Iterable[DecisionEvent], dest: GitEventStore) -> int:
"""Replay ``events`` (in space/seq order) into ``dest``. Returns the count imported."""
count = 0
for event in events:
dest.import_event(event)
count += 1
return count
def migrate_space(source: EventStore, space: str, dest: GitEventStore) -> int:
"""Migrate one space's log from any :class:`EventStore` into the git backend verbatim."""
return import_log(source.events(space), dest)
def export_jsonl(events: Iterable[DecisionEvent], path: str | Path) -> int:
"""Write events as newline-delimited canonical JSON (a portable, diffable log export)."""
count = 0
with open(path, "wb") as handle:
for event in events:
handle.write(serialize_event(event) + b"\n")
count += 1
return count
def import_jsonl(path: str | Path, dest: GitEventStore) -> int:
"""Replay a JSON-lines export (see :func:`export_jsonl`) into the git backend."""
with open(path, "rb") as handle:
events = [deserialize_event(line) for line in handle if line.strip()]
return import_log(events, dest)

View file

@ -8,11 +8,15 @@ a network API is a later workplan.
from __future__ import annotations
from pathlib import Path
from shard_wiki.adapters import ShardAdapter, assert_conformant
from shard_wiki.coordination import (
ApplyResult,
DecisionLog,
EventStore,
EventType,
GitEventStore,
Overlay,
OverlayEngine,
)
@ -24,12 +28,31 @@ __all__ = ["InformationSpace"]
class InformationSpace:
def __init__(self, space_id: str, policy: Policy = DEFAULT_POLICY) -> None:
def __init__(
self,
space_id: str,
policy: Policy = DEFAULT_POLICY,
*,
store: EventStore | None = None,
) -> None:
"""Tie the slice together. ``store`` selects the coordination-log backend: the default
in-memory store (tests) or a git-addressable one. Use :meth:`git_backed` for the latter."""
self.space_id = space_id
self.log = DecisionLog()
self.log = DecisionLog(store)
self.union = UnionGraph(space_id, log=self.log, policy=policy)
self.overlays = OverlayEngine(space_id, self.log)
@classmethod
def git_backed(
cls,
space_id: str,
repo_path: str | Path,
policy: Policy = DEFAULT_POLICY,
) -> InformationSpace:
"""An information space whose coordination log is git-addressable (history/patch/review/
backup I-6). The decision log lives in the git repo at ``repo_path``."""
return cls(space_id, policy, store=GitEventStore(repo_path))
def attach(self, adapter: ShardAdapter) -> None:
"""Attach a shard — only if it passes conformance (verified profile, I-3/§6.6)."""
assert_conformant(adapter)