From d797bc5ee4f507170907f0cdc0e7244c400d67d9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 11:56:53 +0200 Subject: [PATCH] feat(coordination): Overlay model + OverlayEngine.draft (WP-0008 T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay value type (id, target, base_rev, body, state) recorded as an OVERLAY_CREATED decision-log event (coordination-canonical); get()/open_overlays() reconstruct from the fold. 4 tests green, pyflakes clean. (ADR-05, blueprint §8.2) Co-Authored-By: Claude Opus 4.8 --- src/shard_wiki/coordination/__init__.py | 10 +++- src/shard_wiki/coordination/overlay.py | 77 +++++++++++++++++++++++++ tests/test_overlay.py | 42 ++++++++++++++ workplans/SHARD-WP-0008-write-path.md | 2 +- 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/shard_wiki/coordination/overlay.py create mode 100644 tests/test_overlay.py diff --git a/src/shard_wiki/coordination/__init__.py b/src/shard_wiki/coordination/__init__.py index d4b8512..52eed65 100644 --- a/src/shard_wiki/coordination/__init__.py +++ b/src/shard_wiki/coordination/__init__.py @@ -6,5 +6,13 @@ from shard_wiki.coordination.decision_log import ( DecisionLog, EventType, ) +from shard_wiki.coordination.overlay import Overlay, OverlayEngine -__all__ = ["DecisionLog", "DecisionEvent", "EventType", "CoordinationState"] +__all__ = [ + "DecisionLog", + "DecisionEvent", + "EventType", + "CoordinationState", + "Overlay", + "OverlayEngine", +] diff --git a/src/shard_wiki/coordination/overlay.py b/src/shard_wiki/coordination/overlay.py new file mode 100644 index 0000000..93ee6a3 --- /dev/null +++ b/src/shard_wiki/coordination/overlay.py @@ -0,0 +1,77 @@ +"""Overlay engine — overlay-before-mutation (FederationRequirements ADR-05, blueprint §8.2). + +An overlay is a non-destructive local edit against a page. It is **coordination-canonical**: an +``OVERLAY_CREATED`` event in the decision log (§8.1), not a mutable side file. The current set +of open overlays is the log fold. ``draft`` records one; ``apply`` (T4) resolves it under drift. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from shard_wiki.coordination.decision_log import DecisionLog, EventType +from shard_wiki.model import Identity +from shard_wiki.provenance import OverlayState + +__all__ = ["Overlay", "OverlayEngine"] + + +@dataclass(frozen=True, slots=True) +class Overlay: + """A non-destructive edit: the proposed ``body`` for ``target``, authored against + ``base_rev`` (the shard revision seen at draft time, for drift detection).""" + + overlay_id: str + target: Identity + base_rev: str | None + body: str + state: OverlayState = OverlayState.DRAFT + + def to_payload(self) -> dict[str, Any]: + return { + "overlay_id": self.overlay_id, + "target_shard": self.target.shard, + "target_key": self.target.key, + "base_rev": self.base_rev, + "body": self.body, + "state": self.state.value, + } + + @classmethod + def from_payload(cls, payload: Mapping[str, Any]) -> Overlay: + return cls( + overlay_id=payload["overlay_id"], + target=Identity(payload["target_shard"], payload["target_key"]), + base_rev=payload["base_rev"], + body=payload["body"], + state=OverlayState(payload.get("state", OverlayState.DRAFT.value)), + ) + + +class OverlayEngine: + def __init__(self, space: str, log: DecisionLog) -> None: + self.space = space + self.log = log + + def draft( + self, + target: Identity, + body: str, + base_rev: str | None, + actor: str | None = None, + ) -> Overlay: + """Create a draft overlay and record it in the decision log (coordination-canonical).""" + overlay = Overlay(uuid.uuid4().hex, target, base_rev, body) + self.log.append(self.space, EventType.OVERLAY_CREATED, overlay.to_payload(), actor=actor) + return overlay + + def get(self, overlay_id: str) -> Overlay | None: + payload = self.log.fold(self.space).open_overlays.get(overlay_id) + return Overlay.from_payload(payload) if payload is not None else None + + def open_overlays(self) -> tuple[Overlay, ...]: + state = self.log.fold(self.space) + return tuple(Overlay.from_payload(p) for p in state.open_overlays.values()) diff --git a/tests/test_overlay.py b/tests/test_overlay.py new file mode 100644 index 0000000..8f487f5 --- /dev/null +++ b/tests/test_overlay.py @@ -0,0 +1,42 @@ +"""Tests for the overlay model + draft (SHARD-WP-0008 T2).""" + +from shard_wiki.coordination import DecisionLog, EventType, OverlayEngine +from shard_wiki.model import Identity +from shard_wiki.provenance import OverlayState + + +def test_draft_records_event_and_is_retrievable(): + log = DecisionLog() + eng = OverlayEngine("space", log) + ov = eng.draft(Identity("shardA", "Home"), "new body", base_rev="r1") + + assert ov.state is OverlayState.DRAFT + assert ov.body == "new body" + # recorded in the log (coordination-canonical) + events = log.events("space") + assert len(events) == 1 and events[0].type is EventType.OVERLAY_CREATED + # retrievable through the fold by its id + got = eng.get(ov.overlay_id) + assert got is not None + assert got.target == Identity("shardA", "Home") + assert got.base_rev == "r1" + assert got.body == "new body" + + +def test_open_overlays_lists_drafts(): + log = DecisionLog() + eng = OverlayEngine("space", log) + eng.draft(Identity("s", "A"), "a", base_rev=None) + eng.draft(Identity("s", "B"), "b", base_rev=None) + assert {o.target.key for o in eng.open_overlays()} == {"A", "B"} + + +def test_unknown_overlay_is_none(): + assert OverlayEngine("space", DecisionLog()).get("nope") is None + + +def test_overlay_payload_round_trips(): + log = DecisionLog() + eng = OverlayEngine("space", log) + ov = eng.draft(Identity("shardA", "sub/Page"), "x", base_rev="r9") + assert eng.get(ov.overlay_id) == ov # payload reconstructs an equal Overlay diff --git a/workplans/SHARD-WP-0008-write-path.md b/workplans/SHARD-WP-0008-write-path.md index ff2c276..c0bf163 100644 --- a/workplans/SHARD-WP-0008-write-path.md +++ b/workplans/SHARD-WP-0008-write-path.md @@ -58,7 +58,7 @@ folder still rejects write; conformance passes for both. ```task id: SHARD-WP-0008-T2 -status: todo +status: done priority: high state_hub_task_id: "cc6bf9a3-667d-468d-972d-dae51931a657" ```