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>
This commit is contained in:
2026-06-15 11:09:43 +02:00
parent e24f0034a0
commit 92d5774baf
4 changed files with 88 additions and 4 deletions

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

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

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