From 92d5774baf4e5351bf87e93b19a5854b80aec9e2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 11:09:43 +0200 Subject: [PATCH] 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 --- src/shard_wiki/adapters/conformance.py | 16 ++++++++++ src/shard_wiki/adapters/folder.py | 26 ++++++++++++++-- tests/test_writable_adapter.py | 41 ++++++++++++++++++++++++++ workplans/SHARD-WP-0008-write-path.md | 9 +++++- 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/test_writable_adapter.py diff --git a/src/shard_wiki/adapters/conformance.py b/src/shard_wiki/adapters/conformance.py index 5bf9a14..404a848 100644 --- a/src/shard_wiki/adapters/conformance.py +++ b/src/shard_wiki/adapters/conformance.py @@ -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): diff --git a/src/shard_wiki/adapters/folder.py b/src/shard_wiki/adapters/folder.py index 3a30c2b..628de00 100644 --- a/src/shard_wiki/adapters/folder.py +++ b/src/shard_wiki/adapters/folder.py @@ -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() diff --git a/tests/test_writable_adapter.py b/tests/test_writable_adapter.py new file mode 100644 index 0000000..2f6cd37 --- /dev/null +++ b/tests/test_writable_adapter.py @@ -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 diff --git a/workplans/SHARD-WP-0008-write-path.md b/workplans/SHARD-WP-0008-write-path.md index 19d876d..ff2c276 100644 --- a/workplans/SHARD-WP-0008-write-path.md +++ b/workplans/SHARD-WP-0008-write-path.md @@ -11,6 +11,7 @@ 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` + @@ -58,6 +60,7 @@ folder still rejects write; conformance passes for both. id: SHARD-WP-0008-T2 status: todo 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) @@ -71,6 +74,7 @@ overlays. Tests: draft recorded + retrievable via fold; overlay id stable. id: SHARD-WP-0008-T3 status: todo 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, @@ -83,6 +87,7 @@ empty patch when unchanged. id: SHARD-WP-0008-T4 status: todo 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 @@ -97,6 +102,7 @@ Tests: ff apply mutates the shard; drift refuses; read-only keeps draft. id: SHARD-WP-0008-T5 status: todo 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 @@ -110,6 +116,7 @@ overlay. Tests: page with a draft reads with overlay_state DRAFT; applied/none r id: SHARD-WP-0008-T6 status: todo priority: medium +state_hub_task_id: "ab01fffb-61ad-416c-9f13-fdfbfd503153" ``` Add `InformationSpace.edit(name, body)` (write-through if the resolved shard supports WRITE,