From 4be2f190a0ee2b2dd20175cc0338925389804264 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 13:29:06 +0200 Subject: [PATCH] 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 --- src/shard_wiki/policy/__init__.py | 3 ++ src/shard_wiki/union/resolver.py | 48 +++++++++++++++++++++---- tests/test_overlay_aware_read.py | 50 +++++++++++++++++++++++++++ workplans/SHARD-WP-0008-write-path.md | 2 +- 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 tests/test_overlay_aware_read.py diff --git a/src/shard_wiki/policy/__init__.py b/src/shard_wiki/policy/__init__.py index 053d2fa..fa39e89 100644 --- a/src/shard_wiki/policy/__init__.py +++ b/src/shard_wiki/policy/__init__.py @@ -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() diff --git a/src/shard_wiki/union/resolver.py b/src/shard_wiki/union/resolver.py index 37684c0..65c8f00 100644 --- a/src/shard_wiki/union/resolver.py +++ b/src/shard_wiki/union/resolver.py @@ -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"] @@ -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) 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 diff --git a/tests/test_overlay_aware_read.py b/tests/test_overlay_aware_read.py new file mode 100644 index 0000000..ff71db6 --- /dev/null +++ b/tests/test_overlay_aware_read.py @@ -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 diff --git a/workplans/SHARD-WP-0008-write-path.md b/workplans/SHARD-WP-0008-write-path.md index 548fbcd..38883f9 100644 --- a/workplans/SHARD-WP-0008-write-path.md +++ b/workplans/SHARD-WP-0008-write-path.md @@ -100,7 +100,7 @@ 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" ```