From 7d00ae758efce8b6a83118b5541936c61a069c73 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 13:25:10 +0200 Subject: [PATCH] feat(coordination): apply-under-drift (WP-0008 T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverlayEngine.apply: read-only target → KEPT_DRAFT; base_rev==current → fast-forward write-through (APPLIED, MERGE_DECIDED closes overlay); drift → REFUSED_DRIFT (no clobber, I-5). 5 tests green, pyflakes clean. (blueprint §8.6) Co-Authored-By: Claude Opus 4.8 --- src/shard_wiki/coordination/__init__.py | 9 +++- src/shard_wiki/coordination/overlay.py | 61 ++++++++++++++++++++++- tests/test_apply.py | 66 +++++++++++++++++++++++++ workplans/SHARD-WP-0008-write-path.md | 2 +- 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 tests/test_apply.py diff --git a/src/shard_wiki/coordination/__init__.py b/src/shard_wiki/coordination/__init__.py index 7fc3be3..d941f6f 100644 --- a/src/shard_wiki/coordination/__init__.py +++ b/src/shard_wiki/coordination/__init__.py @@ -6,7 +6,12 @@ from shard_wiki.coordination.decision_log import ( DecisionLog, EventType, ) -from shard_wiki.coordination.overlay import Overlay, OverlayEngine +from shard_wiki.coordination.overlay import ( + ApplyResult, + ApplyStatus, + Overlay, + OverlayEngine, +) from shard_wiki.coordination.patch import Patch, render_patch __all__ = [ @@ -16,6 +21,8 @@ __all__ = [ "CoordinationState", "Overlay", "OverlayEngine", + "ApplyStatus", + "ApplyResult", "Patch", "render_patch", ] diff --git a/src/shard_wiki/coordination/overlay.py b/src/shard_wiki/coordination/overlay.py index 93ee6a3..a5f920f 100644 --- a/src/shard_wiki/coordination/overlay.py +++ b/src/shard_wiki/coordination/overlay.py @@ -10,13 +10,15 @@ from __future__ import annotations import uuid from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum from typing import Any +from shard_wiki.adapters import ShardAdapter from shard_wiki.coordination.decision_log import DecisionLog, EventType -from shard_wiki.model import Identity +from shard_wiki.model import Identity, Page, Verb from shard_wiki.provenance import OverlayState -__all__ = ["Overlay", "OverlayEngine"] +__all__ = ["Overlay", "OverlayEngine", "ApplyStatus", "ApplyResult"] @dataclass(frozen=True, slots=True) @@ -51,6 +53,20 @@ class Overlay: ) +class ApplyStatus(Enum): + APPLIED = "applied" # fast-forwarded and written through + REFUSED_DRIFT = "refused-drift" # source moved under the overlay; no clobber + KEPT_DRAFT = "kept-draft" # target read-only; overlay remains the local truth + + +@dataclass(frozen=True, slots=True) +class ApplyResult: + status: ApplyStatus + overlay_id: str + page: Page | None = None + detail: str = "" + + class OverlayEngine: def __init__(self, space: str, log: DecisionLog) -> None: self.space = space @@ -75,3 +91,44 @@ class OverlayEngine: 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()) + + def apply(self, overlay_id: str, adapter: ShardAdapter) -> ApplyResult: + """Resolve an overlay against its target shard with apply-under-drift semantics (§8.6). + + Read-only target → ``KEPT_DRAFT`` (the overlay stays the local truth). Otherwise compare + the overlay's ``base_rev`` to the shard's current rev: equal → fast-forward write-through + (``APPLIED``); changed → ``REFUSED_DRIFT`` (never clobber, I-5). Applying records a + ``MERGE_DECIDED`` event that closes the overlay in the fold. + """ + overlay = self.get(overlay_id) + if overlay is None: + raise KeyError(overlay_id) + if adapter.shard_id != overlay.target.shard: + raise ValueError(f"adapter {adapter.shard_id!r} != target {overlay.target.shard!r}") + + if not adapter.profile().supports(Verb.WRITE): + return ApplyResult( + ApplyStatus.KEPT_DRAFT, overlay_id, detail="target is read-only; overlay retained" + ) + + current = _current_rev(adapter, overlay.target.key) + if current != overlay.base_rev: + return ApplyResult( + ApplyStatus.REFUSED_DRIFT, + overlay_id, + detail=f"base_rev {overlay.base_rev!r} != current {current!r}", + ) + + page = adapter.write(overlay.target.key, overlay.body) + self.log.append( + self.space, + EventType.MERGE_DECIDED, + {"overlay_id": overlay_id, "outcome": "applied"}, + ) + return ApplyResult(ApplyStatus.APPLIED, overlay_id, page=page) + + +def _current_rev(adapter: ShardAdapter, key: str) -> str | None: + """Best-effort current-revision probe; adapters without one are treated as no-rev.""" + probe = getattr(adapter, "current_rev", None) + return probe(key) if callable(probe) else None diff --git a/tests/test_apply.py b/tests/test_apply.py new file mode 100644 index 0000000..77c5d36 --- /dev/null +++ b/tests/test_apply.py @@ -0,0 +1,66 @@ +"""Tests for apply-under-drift (SHARD-WP-0008 T4).""" + +from shard_wiki.adapters import FolderAdapter +from shard_wiki.coordination import ApplyStatus, DecisionLog, OverlayEngine +from shard_wiki.model import Identity + + +def _writable(tmp_path, files): + for rel, text in files.items(): + (tmp_path / rel).write_text(text, encoding="utf-8") + return FolderAdapter("w", tmp_path, writable=True) + + +def test_fast_forward_apply_writes_through(tmp_path): + shard = _writable(tmp_path, {"Home.md": "old"}) + eng = OverlayEngine("space", DecisionLog()) + base = shard.current_rev("Home") + ov = eng.draft(Identity("w", "Home"), "new", base_rev=base) + + result = eng.apply(ov.overlay_id, shard) + assert result.status is ApplyStatus.APPLIED + assert shard.read("Home").body == "new" # written through + assert eng.get(ov.overlay_id) is None # overlay closed in the fold + + +def test_drift_refuses_without_clobber(tmp_path): + shard = _writable(tmp_path, {"Home.md": "old"}) + eng = OverlayEngine("space", DecisionLog()) + ov = eng.draft(Identity("w", "Home"), "mine", base_rev="STALE-REV") + + result = eng.apply(ov.overlay_id, shard) + assert result.status is ApplyStatus.REFUSED_DRIFT + assert shard.read("Home").body == "old" # not clobbered (I-5) + assert eng.get(ov.overlay_id) is not None # overlay still open + + +def test_read_only_target_keeps_draft(tmp_path): + (tmp_path / "Home.md").write_text("canon", encoding="utf-8") + ro = FolderAdapter("ro", tmp_path) # not writable + eng = OverlayEngine("space", DecisionLog()) + ov = eng.draft(Identity("ro", "Home"), "my edit", base_rev=ro.current_rev("Home")) + + result = eng.apply(ov.overlay_id, ro) + assert result.status is ApplyStatus.KEPT_DRAFT + assert ro.read("Home").body == "canon" # source untouched + assert eng.get(ov.overlay_id) is not None # local truth retained + + +def test_new_page_fast_forwards(tmp_path): + shard = _writable(tmp_path, {}) + eng = OverlayEngine("space", DecisionLog()) + ov = eng.draft(Identity("w", "Fresh"), "brand new", base_rev=None) # didn't exist + result = eng.apply(ov.overlay_id, shard) + assert result.status is ApplyStatus.APPLIED + assert shard.read("Fresh").body == "brand new" + + +def test_wrong_adapter_is_rejected(tmp_path): + shard = _writable(tmp_path, {"Home.md": "x"}) + eng = OverlayEngine("space", DecisionLog()) + ov = eng.draft(Identity("other", "Home"), "y", base_rev=None) + try: + eng.apply(ov.overlay_id, shard) + raise AssertionError("expected ValueError") + except ValueError: + pass diff --git a/workplans/SHARD-WP-0008-write-path.md b/workplans/SHARD-WP-0008-write-path.md index 3cb8119..548fbcd 100644 --- a/workplans/SHARD-WP-0008-write-path.md +++ b/workplans/SHARD-WP-0008-write-path.md @@ -85,7 +85,7 @@ empty patch when unchanged. ```task id: SHARD-WP-0008-T4 -status: todo +status: done priority: high state_hub_task_id: "2a0179b1-802e-44e6-883d-9f1babefee80" ```