feat(coordination): apply-under-drift (WP-0008 T4)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 13:25:10 +02:00
parent 715ab1ca00
commit 7d00ae758e
4 changed files with 134 additions and 4 deletions

View File

@@ -6,7 +6,12 @@ from shard_wiki.coordination.decision_log import (
DecisionLog, DecisionLog,
EventType, 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 from shard_wiki.coordination.patch import Patch, render_patch
__all__ = [ __all__ = [
@@ -16,6 +21,8 @@ __all__ = [
"CoordinationState", "CoordinationState",
"Overlay", "Overlay",
"OverlayEngine", "OverlayEngine",
"ApplyStatus",
"ApplyResult",
"Patch", "Patch",
"render_patch", "render_patch",
] ]

View File

@@ -10,13 +10,15 @@ from __future__ import annotations
import uuid import uuid
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from typing import Any from typing import Any
from shard_wiki.adapters import ShardAdapter
from shard_wiki.coordination.decision_log import DecisionLog, EventType 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 from shard_wiki.provenance import OverlayState
__all__ = ["Overlay", "OverlayEngine"] __all__ = ["Overlay", "OverlayEngine", "ApplyStatus", "ApplyResult"]
@dataclass(frozen=True, slots=True) @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: class OverlayEngine:
def __init__(self, space: str, log: DecisionLog) -> None: def __init__(self, space: str, log: DecisionLog) -> None:
self.space = space self.space = space
@@ -75,3 +91,44 @@ class OverlayEngine:
def open_overlays(self) -> tuple[Overlay, ...]: def open_overlays(self) -> tuple[Overlay, ...]:
state = self.log.fold(self.space) state = self.log.fold(self.space)
return tuple(Overlay.from_payload(p) for p in state.open_overlays.values()) 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

66
tests/test_apply.py Normal file
View File

@@ -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

View File

@@ -85,7 +85,7 @@ empty patch when unchanged.
```task ```task
id: SHARD-WP-0008-T4 id: SHARD-WP-0008-T4
status: todo status: done
priority: high priority: high
state_hub_task_id: "2a0179b1-802e-44e6-883d-9f1babefee80" state_hub_task_id: "2a0179b1-802e-44e6-883d-9f1babefee80"
``` ```