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,
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",
]

View File

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