Compare commits

...

6 Commits

Author SHA1 Message Date
3ea0cc1226 feat(space): wire write path into InformationSpace; integration (WP-0008 T6)
edit()/overlay()/apply_overlay() on InformationSpace. edit() unifies the write
path through one principled route — draft overlay then apply: write-through-capable
target fast-forwards (APPLIED), read-only target keeps the draft as local truth
(KEPT_DRAFT), external drift refuses (no clobber). Integration tests cover all
four. 64 tests green, pyflakes clean. Flips WP-0008 done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:46:32 +02:00
4be2f190a0 feat(union): overlay-aware resolution (WP-0008 T5)
resolve() layers open overlays onto canonical pages (overlay_state=DRAFT always
surfaced; overlaid body projected when policy.show_drafts); draft-only edits make
a not-yet-existing page resolvable. Never hides an unapplied overlay (I-4). Policy
gains show_drafts. 4 tests green, pyflakes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:29:06 +02:00
7d00ae758e 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>
2026-06-15 13:25:10 +02:00
715ab1ca00 feat(coordination): patch rendering (unified diff) (WP-0008 T3)
render_patch(overlay, base_body) → Patch (pure difflib unified diff, target-
labelled); is_empty when unchanged. 3 tests green, pyflakes clean. (ADR-05)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:32:47 +02:00
d797bc5ee4 feat(coordination): Overlay model + OverlayEngine.draft (WP-0008 T2)
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 <noreply@anthropic.com>
2026-06-15 11:56:53 +02:00
92d5774baf feat(adapters): writable FolderAdapter + positive write conformance (WP-0008 T1)
FolderAdapter(writable=True) declares WRITE+PER_PAGE, implements write() and
current_rev() (mtime token for drift detection). Conformance gains a
content-preserving positive write probe for WRITE-claiming adapters. 5 tests
green, full suite green, pyflakes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:09:43 +02:00
16 changed files with 608 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View 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))

View File

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

View File

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

View File

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

42
tests/test_overlay.py Normal file
View 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

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

View 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

View 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

View File

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