From 3ea0cc1226866fb4155bc8ca8727b5f1b32cb382 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 13:46:32 +0200 Subject: [PATCH] feat(space): wire write path into InformationSpace; integration (WP-0008 T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SCOPE.md | 2 +- src/shard_wiki/space.py | 31 +++++++++++++- src/shard_wiki/union/resolver.py | 6 +-- tests/test_write_path_integration.py | 58 +++++++++++++++++++++++++++ workplans/SHARD-WP-0008-write-path.md | 4 +- 5 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 tests/test_write_path_integration.py diff --git a/SCOPE.md b/SCOPE.md index 8d2621d..822a5de 100644 --- a/SCOPE.md +++ b/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 | diff --git a/src/shard_wiki/space.py b/src/shard_wiki/space.py index b8547f4..2f0b3d9 100644 --- a/src/shard_wiki/space.py +++ b/src/shard_wiki/space.py @@ -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) diff --git a/src/shard_wiki/union/resolver.py b/src/shard_wiki/union/resolver.py index 65c8f00..9fcfce2 100644 --- a/src/shard_wiki/union/resolver.py +++ b/src/shard_wiki/union/resolver.py @@ -65,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]: @@ -88,7 +88,7 @@ class UnionGraph: 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 = self._with_overlay(shard.read(key), overlays) @@ -127,7 +127,7 @@ class UnionGraph: 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: + if identity.key != name or identity in have or self.shard(identity.shard) is None: continue env = ProvenanceEnvelope( source_shard=identity.shard, diff --git a/tests/test_write_path_integration.py b/tests/test_write_path_integration.py new file mode 100644 index 0000000..b8d98b4 --- /dev/null +++ b/tests/test_write_path_integration.py @@ -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 diff --git a/workplans/SHARD-WP-0008-write-path.md b/workplans/SHARD-WP-0008-write-path.md index 38883f9..87431d5 100644 --- a/workplans/SHARD-WP-0008-write-path.md +++ b/workplans/SHARD-WP-0008-write-path.md @@ -4,7 +4,7 @@ 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" @@ -114,7 +114,7 @@ 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" ```