generated from coulomb/repo-seed
Compare commits
6 Commits
e24f0034a0
...
3ea0cc1226
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ea0cc1226 | |||
| 4be2f190a0 | |||
| 7d00ae758e | |||
| 715ab1ca00 | |||
| d797bc5ee4 | |||
| 92d5774baf |
2
SCOPE.md
2
SCOPE.md
@@ -17,7 +17,7 @@ Learnings update both SCOPE and INTENT where necessary.
|
||||
|
||||
| Layer | State |
|
||||
|-------|-------|
|
||||
| Code | Foundation slice implemented (SHARD-WP-0007): `provenance` + `policy` leaves, `model` (Identity/Placement/Span/Page/CapabilityProfile), `adapters` (contract + FolderAdapter + conformance suite), `coordination` (event-sourced DecisionLog), `union` (resolution + chorus), `InformationSpace` orchestrator — attach→resolve→read works; 39 tests green |
|
||||
| Code | Foundation slice implemented (SHARD-WP-0007): `provenance` + `policy` leaves, `model` (Identity/Placement/Span/Page/CapabilityProfile), `adapters` (contract + FolderAdapter + conformance suite), `coordination` (event-sourced DecisionLog), `union` (resolution + chorus, overlay-aware), `InformationSpace` orchestrator. Write path added (SHARD-WP-0008): writable adapter, overlay engine (draft→patch→apply-under-drift), edit() unifies write-through + overlay-before-mutation. attach→resolve→read + edit/overlay/apply work; 64 tests green |
|
||||
| Intent | `INTENT.md` established; authorization-in-core amendments drafted |
|
||||
| Research | yawex prior art; c2 origins; federation concepts; wikiengines overview (`research/260608-*/`); XWiki/TWiki/Foswiki deep dives (`research/260613-*/`); Xanadu + ZigZag + Roam + Obsidian + Notion + Joplin + Logseq + local-first workspaces (Anytype/AFFiNE/AppFlowy) + Trilium + Wiki.js + Federated Wiki + Wikibase + git-forge wikis + TiddlyWiki + ikiwiki + Quip + MojoMojo + Oddmuse + UseModWiki deep dives & shard-spectrum synthesis (`research/260614-*/`) |
|
||||
| Demand | NetKingdom integration asks captured, not yet negotiated |
|
||||
|
||||
@@ -98,6 +98,22 @@ def run_conformance(adapter: ShardAdapter) -> ConformanceReport:
|
||||
if profile.supports(Verb.READ):
|
||||
checks.append(_safe(_read_round_trips, "read-round-trips"))
|
||||
|
||||
# WRITE positive probe: a claimed-writable shard must actually round-trip a write. The probe
|
||||
# is content-preserving (rewrite an existing page with its own body) so it is non-destructive.
|
||||
def _write_round_trips() -> Check:
|
||||
keys = list(adapter.keys())
|
||||
if not keys:
|
||||
return Check("write-round-trips", True, "empty shard")
|
||||
k = keys[0]
|
||||
original = adapter.read(k).body
|
||||
adapter.write(k, original)
|
||||
if adapter.read(k).body != original:
|
||||
return Check("write-round-trips", False, "rewrite did not preserve body")
|
||||
return Check("write-round-trips", True)
|
||||
|
||||
if profile.supports(Verb.WRITE):
|
||||
checks.append(_safe(_write_round_trips, "write-round-trips"))
|
||||
|
||||
# Honest absence: an *unclaimed* optional verb must raise NotSupported when invoked.
|
||||
for verb in _HONEST_ABSENCE_VERBS:
|
||||
if profile.supports(verb):
|
||||
|
||||
@@ -23,6 +23,7 @@ from shard_wiki.model import (
|
||||
Identity,
|
||||
MergeModel,
|
||||
NativeQuery,
|
||||
NotSupported,
|
||||
OperationalEnvelope,
|
||||
Page,
|
||||
Placement,
|
||||
@@ -37,19 +38,22 @@ __all__ = ["FolderAdapter"]
|
||||
|
||||
|
||||
class FolderAdapter(ShardAdapter):
|
||||
def __init__(self, shard_id: str, root: str | Path) -> None:
|
||||
def __init__(self, shard_id: str, root: str | Path, writable: bool = False) -> None:
|
||||
self._shard_id = shard_id
|
||||
self._root = Path(root)
|
||||
self._writable = writable
|
||||
|
||||
@property
|
||||
def shard_id(self) -> str:
|
||||
return self._shard_id
|
||||
|
||||
def profile(self) -> CapabilityProfile:
|
||||
verbs = {Verb.READ, Verb.WRITE} if self._writable else {Verb.READ}
|
||||
granularity = WriteGranularity.PER_PAGE if self._writable else WriteGranularity.NONE
|
||||
return CapabilityProfile(
|
||||
substrate=Substrate.FILES,
|
||||
attachment_mode=AttachmentMode.FILE_STORE,
|
||||
write_granularity=WriteGranularity.NONE,
|
||||
write_granularity=granularity,
|
||||
content_opacity=ContentOpacity.TRANSPARENT,
|
||||
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||
access_grant=AccessGrant.OPEN,
|
||||
@@ -59,12 +63,28 @@ class FolderAdapter(ShardAdapter):
|
||||
addressing=Addressing.PATH,
|
||||
native_query=NativeQuery.NONE,
|
||||
translation=Translation.NATIVE,
|
||||
supported_verbs=frozenset({Verb.READ}),
|
||||
supported_verbs=frozenset(verbs),
|
||||
).validate()
|
||||
|
||||
def _path_for(self, key: str) -> Path:
|
||||
return self._root / f"{key}.md"
|
||||
|
||||
def current_rev(self, key: str) -> str | None:
|
||||
"""The shard's current revision token for ``key`` (mtime iso), or ``None`` if absent.
|
||||
Used for apply-under-drift comparison (blueprint §8.6)."""
|
||||
path = self._path_for(key)
|
||||
if not path.is_file():
|
||||
return None
|
||||
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
|
||||
|
||||
def write(self, key: str, body: str) -> Page:
|
||||
if not self._writable:
|
||||
raise NotSupported(f"{type(self).__name__} is read-only")
|
||||
path = self._path_for(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return self.read(key)
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
for p in sorted(self._root.rglob("*.md")):
|
||||
yield p.relative_to(self._root).with_suffix("").as_posix()
|
||||
|
||||
@@ -6,5 +6,23 @@ from shard_wiki.coordination.decision_log import (
|
||||
DecisionLog,
|
||||
EventType,
|
||||
)
|
||||
from shard_wiki.coordination.overlay import (
|
||||
ApplyResult,
|
||||
ApplyStatus,
|
||||
Overlay,
|
||||
OverlayEngine,
|
||||
)
|
||||
from shard_wiki.coordination.patch import Patch, render_patch
|
||||
|
||||
__all__ = ["DecisionLog", "DecisionEvent", "EventType", "CoordinationState"]
|
||||
__all__ = [
|
||||
"DecisionLog",
|
||||
"DecisionEvent",
|
||||
"EventType",
|
||||
"CoordinationState",
|
||||
"Overlay",
|
||||
"OverlayEngine",
|
||||
"ApplyStatus",
|
||||
"ApplyResult",
|
||||
"Patch",
|
||||
"render_patch",
|
||||
]
|
||||
|
||||
134
src/shard_wiki/coordination/overlay.py
Normal file
134
src/shard_wiki/coordination/overlay.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""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 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, Page, Verb
|
||||
from shard_wiki.provenance import OverlayState
|
||||
|
||||
__all__ = ["Overlay", "OverlayEngine", "ApplyStatus", "ApplyResult"]
|
||||
|
||||
|
||||
@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 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
|
||||
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())
|
||||
|
||||
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
|
||||
38
src/shard_wiki/coordination/patch.py
Normal file
38
src/shard_wiki/coordination/patch.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Patch rendering — an overlay as a reviewable diff (FederationRequirements ADR-05).
|
||||
|
||||
A pure function over (base body, overlay body): the auditable change proposal that an overlay
|
||||
becomes before it is applied. Markdown/native text in this slice (lossless); a lossy
|
||||
native-syntax-with-fidelity-report variant is later (TSD §A.6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shard_wiki.coordination.overlay import Overlay
|
||||
from shard_wiki.model import Identity
|
||||
|
||||
__all__ = ["Patch", "render_patch"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Patch:
|
||||
target: Identity
|
||||
diff: str
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.diff == ""
|
||||
|
||||
|
||||
def render_patch(overlay: Overlay, base_body: str) -> Patch:
|
||||
"""Render ``base_body`` → ``overlay.body`` as a unified diff against the overlay target."""
|
||||
label = str(overlay.target)
|
||||
lines = difflib.unified_diff(
|
||||
base_body.splitlines(keepends=True),
|
||||
overlay.body.splitlines(keepends=True),
|
||||
fromfile=f"a/{label}",
|
||||
tofile=f"b/{label}",
|
||||
)
|
||||
return Patch(target=overlay.target, diff="".join(lines))
|
||||
@@ -31,6 +31,9 @@ class Policy:
|
||||
|
||||
canonical_source: CanonicalSource = CanonicalSource.CHORUS
|
||||
designated_shard: str | None = None
|
||||
# Whether an unapplied overlay's body is projected over its canonical page on read. Either
|
||||
# way the overlay is never *hidden* — overlay_state is always surfaced in provenance.
|
||||
show_drafts: bool = True
|
||||
|
||||
|
||||
DEFAULT_POLICY = Policy()
|
||||
|
||||
@@ -9,7 +9,13 @@ a network API is a later workplan.
|
||||
from __future__ import annotations
|
||||
|
||||
from shard_wiki.adapters import ShardAdapter, assert_conformant
|
||||
from shard_wiki.coordination import DecisionLog, EventType
|
||||
from shard_wiki.coordination import (
|
||||
ApplyResult,
|
||||
DecisionLog,
|
||||
EventType,
|
||||
Overlay,
|
||||
OverlayEngine,
|
||||
)
|
||||
from shard_wiki.model import Page
|
||||
from shard_wiki.policy import DEFAULT_POLICY, Policy
|
||||
from shard_wiki.union import Resolution, UnionGraph
|
||||
@@ -22,6 +28,7 @@ class InformationSpace:
|
||||
self.space_id = space_id
|
||||
self.log = DecisionLog()
|
||||
self.union = UnionGraph(space_id, log=self.log, policy=policy)
|
||||
self.overlays = OverlayEngine(space_id, self.log)
|
||||
|
||||
def attach(self, adapter: ShardAdapter) -> None:
|
||||
"""Attach a shard — only if it passes conformance (verified profile, I-3/§6.6)."""
|
||||
@@ -40,3 +47,25 @@ class InformationSpace:
|
||||
def read(self, name: str) -> Page:
|
||||
"""Resolve and return the page (or the canonical pick of a chorus). KeyError if red-link."""
|
||||
return self.union.resolve(name).single()
|
||||
|
||||
def overlay(self, name: str, body: str, actor: str | None = None) -> Overlay:
|
||||
"""Draft a non-destructive overlay against the resolved page (overlay-before-mutation)."""
|
||||
page = self.read(name)
|
||||
return self.overlays.draft(page.identity, body, page.envelope.source_rev, actor=actor)
|
||||
|
||||
def apply_overlay(self, overlay_id: str) -> ApplyResult:
|
||||
"""Apply a draft overlay to its target shard (apply-under-drift, §8.6)."""
|
||||
overlay = self.overlays.get(overlay_id)
|
||||
if overlay is None:
|
||||
raise KeyError(overlay_id)
|
||||
adapter = self.union.shard(overlay.target.shard)
|
||||
if adapter is None:
|
||||
raise KeyError(overlay.target.shard)
|
||||
return self.overlays.apply(overlay_id, adapter)
|
||||
|
||||
def edit(self, name: str, body: str, actor: str | None = None) -> ApplyResult:
|
||||
"""Edit a page through the one principled path: draft an overlay, then apply it. A
|
||||
write-through-capable target fast-forwards (write-through); a read-only target keeps the
|
||||
draft as local truth (I-5: overlay before mutation, always)."""
|
||||
overlay = self.overlay(name, body, actor=actor)
|
||||
return self.apply_overlay(overlay.overlay_id)
|
||||
|
||||
@@ -17,9 +17,10 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from shard_wiki.adapters import ShardAdapter
|
||||
from shard_wiki.coordination import DecisionLog
|
||||
from shard_wiki.model import Page
|
||||
from shard_wiki.coordination import DecisionLog, Overlay
|
||||
from shard_wiki.model import Identity, Page
|
||||
from shard_wiki.policy import DEFAULT_POLICY, CanonicalSource, Policy
|
||||
from shard_wiki.provenance import OverlayState, ProvenanceEnvelope, Staleness
|
||||
|
||||
__all__ = ["ResolutionKind", "Resolution", "UnionGraph"]
|
||||
|
||||
@@ -64,7 +65,7 @@ class UnionGraph:
|
||||
def attach(self, adapter: ShardAdapter) -> None:
|
||||
self._shards.append(adapter)
|
||||
|
||||
def _shard(self, shard_id: str) -> ShardAdapter | None:
|
||||
def shard(self, shard_id: str) -> ShardAdapter | None:
|
||||
return next((s for s in self._shards if s.shard_id == shard_id), None)
|
||||
|
||||
def _read_all(self, key: str) -> list[Page]:
|
||||
@@ -77,21 +78,28 @@ class UnionGraph:
|
||||
return pages
|
||||
|
||||
def resolve(self, name: str) -> Resolution:
|
||||
# 1. alias redirect (coordination-canonical, via the log fold)
|
||||
state = self.log.fold(self.space)
|
||||
overlays = {
|
||||
Overlay.from_payload(p).target: Overlay.from_payload(p)
|
||||
for p in state.open_overlays.values()
|
||||
}
|
||||
|
||||
# 1. alias redirect (coordination-canonical, via the log fold)
|
||||
target = state.resolve_alias(name)
|
||||
if target is not None and ":" in target:
|
||||
shard_id, _, key = target.partition(":")
|
||||
shard = self._shard(shard_id)
|
||||
shard = self.shard(shard_id)
|
||||
if shard is not None:
|
||||
try:
|
||||
page = shard.read(key)
|
||||
page = self._with_overlay(shard.read(key), overlays)
|
||||
return Resolution(name, ResolutionKind.SINGLE, (page,))
|
||||
except KeyError:
|
||||
pass # alias dangles → fall through to normal resolution
|
||||
|
||||
# 2/3. union lookup by key across shards
|
||||
pages = self._read_all(name)
|
||||
# 2/3. union lookup by key across shards, then layer open overlays
|
||||
pages = [self._with_overlay(p, overlays) for p in self._read_all(name)]
|
||||
pages.extend(self._draft_only_pages(name, pages, overlays))
|
||||
|
||||
if not pages:
|
||||
return Resolution(name, ResolutionKind.RED_LINK)
|
||||
if len(pages) == 1:
|
||||
@@ -102,6 +110,34 @@ class UnionGraph:
|
||||
marked = tuple(_with_divergence(p, ordered) for p in ordered)
|
||||
return Resolution(name, ResolutionKind.CHORUS, marked)
|
||||
|
||||
def _with_overlay(self, page: Page, overlays: dict[Identity, Overlay]) -> Page:
|
||||
"""Mark a canonical page that has an open overlay (overlay_state DRAFT; project the
|
||||
overlaid body where policy shows drafts) — never hides the overlay (I-4)."""
|
||||
overlay = overlays.get(page.identity)
|
||||
if overlay is None:
|
||||
return page
|
||||
env = dataclasses.replace(page.envelope, overlay_state=OverlayState.DRAFT)
|
||||
body = overlay.body if self.policy.show_drafts else page.body
|
||||
return dataclasses.replace(page, body=body, envelope=env)
|
||||
|
||||
def _draft_only_pages(
|
||||
self, name: str, existing: list[Page], overlays: dict[Identity, Overlay]
|
||||
) -> list[Page]:
|
||||
"""Drafts that create a not-yet-existing page on an attached shard become resolvable."""
|
||||
have = {p.identity for p in existing}
|
||||
out: list[Page] = []
|
||||
for identity, overlay in overlays.items():
|
||||
if identity.key != name or identity in have or self.shard(identity.shard) is None:
|
||||
continue
|
||||
env = ProvenanceEnvelope(
|
||||
source_shard=identity.shard,
|
||||
staleness=Staleness.FRESH,
|
||||
overlay_state=OverlayState.DRAFT,
|
||||
)
|
||||
body = overlay.body if self.policy.show_drafts else ""
|
||||
out.append(Page(identity=identity, body=body, envelope=env))
|
||||
return out
|
||||
|
||||
def _order_for_policy(self, pages: list[Page]) -> list[Page]:
|
||||
if (
|
||||
self.policy.canonical_source is CanonicalSource.DESIGNATED_CANONICAL
|
||||
|
||||
66
tests/test_apply.py
Normal file
66
tests/test_apply.py
Normal 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
|
||||
42
tests/test_overlay.py
Normal file
42
tests/test_overlay.py
Normal file
@@ -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
|
||||
50
tests/test_overlay_aware_read.py
Normal file
50
tests/test_overlay_aware_read.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Tests for overlay-aware union read (SHARD-WP-0008 T5)."""
|
||||
|
||||
from shard_wiki.adapters import FolderAdapter
|
||||
from shard_wiki.coordination import DecisionLog, OverlayEngine
|
||||
from shard_wiki.model import Identity
|
||||
from shard_wiki.policy import Policy
|
||||
from shard_wiki.provenance import OverlayState
|
||||
from shard_wiki.union import ResolutionKind, UnionGraph
|
||||
|
||||
|
||||
def _union(tmp_path, files, policy=None):
|
||||
for rel, text in files.items():
|
||||
(tmp_path / rel).write_text(text, encoding="utf-8")
|
||||
log = DecisionLog()
|
||||
u = UnionGraph("space", log=log, policy=policy) if policy else UnionGraph("space", log=log)
|
||||
u.attach(FolderAdapter("wikiA", tmp_path))
|
||||
return u, OverlayEngine("space", log)
|
||||
|
||||
|
||||
def test_no_overlay_reads_clean(tmp_path):
|
||||
u, _ = _union(tmp_path, {"Home.md": "canon"})
|
||||
page = u.resolve("Home").single()
|
||||
assert page.body == "canon"
|
||||
assert page.envelope.overlay_state is OverlayState.NONE
|
||||
|
||||
|
||||
def test_open_overlay_surfaces_draft_and_projects_body(tmp_path):
|
||||
u, eng = _union(tmp_path, {"Home.md": "canon"})
|
||||
eng.draft(Identity("wikiA", "Home"), "my draft", base_rev=None)
|
||||
page = u.resolve("Home").single()
|
||||
assert page.envelope.overlay_state is OverlayState.DRAFT # never hidden
|
||||
assert page.body == "my draft" # projected (show_drafts default True)
|
||||
|
||||
|
||||
def test_show_drafts_false_keeps_canonical_body_but_still_flags(tmp_path):
|
||||
u, eng = _union(tmp_path, {"Home.md": "canon"}, policy=Policy(show_drafts=False))
|
||||
eng.draft(Identity("wikiA", "Home"), "my draft", base_rev=None)
|
||||
page = u.resolve("Home").single()
|
||||
assert page.body == "canon" # not projected
|
||||
assert page.envelope.overlay_state is OverlayState.DRAFT # but still surfaced (I-4)
|
||||
|
||||
|
||||
def test_draft_only_new_page_becomes_resolvable(tmp_path):
|
||||
u, eng = _union(tmp_path, {"Home.md": "x"})
|
||||
assert u.resolve("Brand").is_red_link # nothing yet
|
||||
eng.draft(Identity("wikiA", "Brand"), "drafted into being", base_rev=None)
|
||||
res = u.resolve("Brand")
|
||||
assert res.kind is ResolutionKind.SINGLE
|
||||
assert res.single().body == "drafted into being"
|
||||
assert res.single().envelope.overlay_state is OverlayState.DRAFT
|
||||
29
tests/test_patch.py
Normal file
29
tests/test_patch.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for patch rendering (SHARD-WP-0008 T3)."""
|
||||
|
||||
from shard_wiki.coordination import Overlay, Patch, render_patch
|
||||
from shard_wiki.model import Identity
|
||||
|
||||
|
||||
def _overlay(body: str) -> Overlay:
|
||||
return Overlay("ov1", Identity("shardA", "Home"), base_rev="r1", body=body)
|
||||
|
||||
|
||||
def test_patch_shows_the_change():
|
||||
patch = render_patch(_overlay("line one\nline TWO\n"), "line one\nline two\n")
|
||||
assert isinstance(patch, Patch)
|
||||
assert not patch.is_empty
|
||||
assert "-line two" in patch.diff
|
||||
assert "+line TWO" in patch.diff
|
||||
assert patch.target == Identity("shardA", "Home")
|
||||
|
||||
|
||||
def test_empty_patch_when_unchanged():
|
||||
patch = render_patch(_overlay("same\n"), "same\n")
|
||||
assert patch.is_empty
|
||||
assert patch.diff == ""
|
||||
|
||||
|
||||
def test_patch_labels_name_the_target():
|
||||
patch = render_patch(_overlay("new\n"), "old\n")
|
||||
assert "a/shardA:Home" in patch.diff
|
||||
assert "b/shardA:Home" in patch.diff
|
||||
41
tests/test_writable_adapter.py
Normal file
41
tests/test_writable_adapter.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Tests for the writable FolderAdapter + positive write conformance (SHARD-WP-0008 T1)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shard_wiki.adapters import FolderAdapter, assert_conformant, run_conformance
|
||||
from shard_wiki.model import NotSupported, Verb
|
||||
|
||||
|
||||
def test_writable_round_trip(tmp_path):
|
||||
shard = FolderAdapter("w", tmp_path, writable=True)
|
||||
rev_before = shard.current_rev("New")
|
||||
assert rev_before is None
|
||||
page = shard.write("New", "hello")
|
||||
assert page.body == "hello"
|
||||
assert shard.read("New").body == "hello"
|
||||
assert shard.current_rev("New") is not None
|
||||
|
||||
|
||||
def test_writable_profile_supports_write(tmp_path):
|
||||
prof = FolderAdapter("w", tmp_path, writable=True).profile()
|
||||
assert prof.supports(Verb.WRITE)
|
||||
|
||||
|
||||
def test_read_only_still_rejects_write(tmp_path):
|
||||
(tmp_path / "Home.md").write_text("x", encoding="utf-8")
|
||||
with pytest.raises(NotSupported):
|
||||
FolderAdapter("ro", tmp_path).write("Home", "y")
|
||||
|
||||
|
||||
def test_conformance_passes_for_writable(tmp_path):
|
||||
(tmp_path / "Home.md").write_text("body", encoding="utf-8")
|
||||
report = assert_conformant(FolderAdapter("w", tmp_path, writable=True))
|
||||
assert report.ok
|
||||
assert any(c.name == "write-round-trips" and c.ok for c in report.checks)
|
||||
|
||||
|
||||
def test_conformance_write_probe_is_content_preserving(tmp_path):
|
||||
(tmp_path / "Home.md").write_text("keep me", encoding="utf-8")
|
||||
shard = FolderAdapter("w", tmp_path, writable=True)
|
||||
run_conformance(shard)
|
||||
assert shard.read("Home").body == "keep me" # probe did not alter content
|
||||
58
tests/test_write_path_integration.py
Normal file
58
tests/test_write_path_integration.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""End-to-end write-path test (SHARD-WP-0008 T6)."""
|
||||
|
||||
from shard_wiki import InformationSpace
|
||||
from shard_wiki.adapters import FolderAdapter
|
||||
from shard_wiki.coordination import ApplyStatus
|
||||
from shard_wiki.provenance import OverlayState
|
||||
|
||||
|
||||
def _folder(tmp_path, name, files, writable=False):
|
||||
root = tmp_path / name
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for rel, text in files.items():
|
||||
(root / rel).write_text(text, encoding="utf-8")
|
||||
return FolderAdapter(name, root, writable=writable)
|
||||
|
||||
|
||||
def test_edit_writethrough_on_writable_shard(tmp_path):
|
||||
space = InformationSpace("team")
|
||||
space.attach(_folder(tmp_path, "wikiW", {"Home.md": "old"}, writable=True))
|
||||
|
||||
result = space.edit("Home", "new content")
|
||||
assert result.status is ApplyStatus.APPLIED
|
||||
assert space.read("Home").body == "new content" # persisted to the shard
|
||||
assert space.read("Home").envelope.overlay_state is OverlayState.NONE # overlay closed
|
||||
|
||||
|
||||
def test_edit_on_readonly_shard_keeps_local_draft(tmp_path):
|
||||
space = InformationSpace("team")
|
||||
space.attach(_folder(tmp_path, "wikiRO", {"Home.md": "canon"}))
|
||||
|
||||
result = space.edit("Home", "my local edit")
|
||||
assert result.status is ApplyStatus.KEPT_DRAFT
|
||||
# source untouched; union shows the draft as local truth, clearly flagged
|
||||
page = space.read("Home")
|
||||
assert page.body == "my local edit"
|
||||
assert page.envelope.overlay_state is OverlayState.DRAFT
|
||||
|
||||
|
||||
def test_explicit_overlay_then_apply(tmp_path):
|
||||
space = InformationSpace("team")
|
||||
space.attach(_folder(tmp_path, "wikiW", {"Doc.md": "v1"}, writable=True))
|
||||
ov = space.overlay("Doc", "v2")
|
||||
assert space.read("Doc").envelope.overlay_state is OverlayState.DRAFT # pending
|
||||
result = space.apply_overlay(ov.overlay_id)
|
||||
assert result.status is ApplyStatus.APPLIED
|
||||
assert space.read("Doc").body == "v2"
|
||||
|
||||
|
||||
def test_stale_overlay_refuses_after_external_change(tmp_path):
|
||||
space = InformationSpace("team")
|
||||
shard = _folder(tmp_path, "wikiW", {"Doc.md": "v1"}, writable=True)
|
||||
space.attach(shard)
|
||||
ov = space.overlay("Doc", "from-v1")
|
||||
# an external write moves the shard under the overlay
|
||||
shard.write("Doc", "v1-prime")
|
||||
result = space.apply_overlay(ov.overlay_id)
|
||||
assert result.status is ApplyStatus.REFUSED_DRIFT
|
||||
assert space.union.shard("wikiW").read("Doc").body == "v1-prime" # not clobbered
|
||||
@@ -4,13 +4,14 @@ type: workplan
|
||||
title: "write path — overlay engine, writable adapter, apply-under-drift"
|
||||
domain: whynot
|
||||
repo: shard-wiki
|
||||
status: active
|
||||
status: done
|
||||
owner: tegwick
|
||||
topic_slug: whynot
|
||||
created: "2026-06-15"
|
||||
updated: "2026-06-15"
|
||||
depends_on:
|
||||
- SHARD-WP-0007
|
||||
state_hub_workstream_id: "12bed418-39d6-47fa-a359-ff04bae6ec99"
|
||||
---
|
||||
|
||||
# SHARD-WP-0008 — Write path
|
||||
@@ -41,8 +42,9 @@ propagation, network API, lossy native-syntax overlays. Those are later.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0008-T1
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "80492f8e-125c-4015-b3c0-821fbec038e0"
|
||||
```
|
||||
|
||||
Make `FolderAdapter` optionally **writable** (`writable=True`): declare `WRITE` +
|
||||
@@ -56,8 +58,9 @@ 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"
|
||||
```
|
||||
|
||||
`coordination/overlay.py`: an `Overlay` value type (id, target identity, base_rev, body, state)
|
||||
@@ -69,8 +72,9 @@ overlays. Tests: draft recorded + retrievable via fold; overlay id stable.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0008-T3
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "90d98c16-ed3b-414f-802c-b0400eca6ede"
|
||||
```
|
||||
|
||||
Render an overlay as a reviewable **patch** (a `Patch` with a unified diff of base→overlay body,
|
||||
@@ -81,8 +85,9 @@ 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"
|
||||
```
|
||||
|
||||
`OverlayEngine.apply(overlay_id)` with §8.6 semantics: compare overlay `base_rev` to the
|
||||
@@ -95,8 +100,9 @@ Tests: ff apply mutates the shard; drift refuses; read-only keeps draft.
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0008-T5
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "4536d74f-3860-4b4c-82d2-e8d20e6e2125"
|
||||
```
|
||||
|
||||
When resolving a page that has an **open overlay**, surface it: the read reflects
|
||||
@@ -108,8 +114,9 @@ overlay. Tests: page with a draft reads with overlay_state DRAFT; applied/none r
|
||||
|
||||
```task
|
||||
id: SHARD-WP-0008-T6
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "ab01fffb-61ad-416c-9f13-fdfbfd503153"
|
||||
```
|
||||
|
||||
Add `InformationSpace.edit(name, body)` (write-through if the resolved shard supports WRITE,
|
||||
|
||||
Reference in New Issue
Block a user