generated from coulomb/repo-seed
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>
This commit is contained in:
2
SCOPE.md
2
SCOPE.md
@@ -17,7 +17,7 @@ Learnings update both SCOPE and INTENT where necessary.
|
|||||||
|
|
||||||
| Layer | State |
|
| 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 |
|
| 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-*/`) |
|
| 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 |
|
| Demand | NetKingdom integration asks captured, not yet negotiated |
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ a network API is a later workplan.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from shard_wiki.adapters import ShardAdapter, assert_conformant
|
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.model import Page
|
||||||
from shard_wiki.policy import DEFAULT_POLICY, Policy
|
from shard_wiki.policy import DEFAULT_POLICY, Policy
|
||||||
from shard_wiki.union import Resolution, UnionGraph
|
from shard_wiki.union import Resolution, UnionGraph
|
||||||
@@ -22,6 +28,7 @@ class InformationSpace:
|
|||||||
self.space_id = space_id
|
self.space_id = space_id
|
||||||
self.log = DecisionLog()
|
self.log = DecisionLog()
|
||||||
self.union = UnionGraph(space_id, log=self.log, policy=policy)
|
self.union = UnionGraph(space_id, log=self.log, policy=policy)
|
||||||
|
self.overlays = OverlayEngine(space_id, self.log)
|
||||||
|
|
||||||
def attach(self, adapter: ShardAdapter) -> None:
|
def attach(self, adapter: ShardAdapter) -> None:
|
||||||
"""Attach a shard — only if it passes conformance (verified profile, I-3/§6.6)."""
|
"""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:
|
def read(self, name: str) -> Page:
|
||||||
"""Resolve and return the page (or the canonical pick of a chorus). KeyError if red-link."""
|
"""Resolve and return the page (or the canonical pick of a chorus). KeyError if red-link."""
|
||||||
return self.union.resolve(name).single()
|
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)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class UnionGraph:
|
|||||||
def attach(self, adapter: ShardAdapter) -> None:
|
def attach(self, adapter: ShardAdapter) -> None:
|
||||||
self._shards.append(adapter)
|
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)
|
return next((s for s in self._shards if s.shard_id == shard_id), None)
|
||||||
|
|
||||||
def _read_all(self, key: str) -> list[Page]:
|
def _read_all(self, key: str) -> list[Page]:
|
||||||
@@ -88,7 +88,7 @@ class UnionGraph:
|
|||||||
target = state.resolve_alias(name)
|
target = state.resolve_alias(name)
|
||||||
if target is not None and ":" in target:
|
if target is not None and ":" in target:
|
||||||
shard_id, _, key = target.partition(":")
|
shard_id, _, key = target.partition(":")
|
||||||
shard = self._shard(shard_id)
|
shard = self.shard(shard_id)
|
||||||
if shard is not None:
|
if shard is not None:
|
||||||
try:
|
try:
|
||||||
page = self._with_overlay(shard.read(key), overlays)
|
page = self._with_overlay(shard.read(key), overlays)
|
||||||
@@ -127,7 +127,7 @@ class UnionGraph:
|
|||||||
have = {p.identity for p in existing}
|
have = {p.identity for p in existing}
|
||||||
out: list[Page] = []
|
out: list[Page] = []
|
||||||
for identity, overlay in overlays.items():
|
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
|
continue
|
||||||
env = ProvenanceEnvelope(
|
env = ProvenanceEnvelope(
|
||||||
source_shard=identity.shard,
|
source_shard=identity.shard,
|
||||||
|
|||||||
58
tests/test_write_path_integration.py
Normal file
58
tests/test_write_path_integration.py
Normal 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
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "write path — overlay engine, writable adapter, apply-under-drift"
|
title: "write path — overlay engine, writable adapter, apply-under-drift"
|
||||||
domain: whynot
|
domain: whynot
|
||||||
repo: shard-wiki
|
repo: shard-wiki
|
||||||
status: active
|
status: done
|
||||||
owner: tegwick
|
owner: tegwick
|
||||||
topic_slug: whynot
|
topic_slug: whynot
|
||||||
created: "2026-06-15"
|
created: "2026-06-15"
|
||||||
@@ -114,7 +114,7 @@ overlay. Tests: page with a draft reads with overlay_state DRAFT; applied/none r
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0008-T6
|
id: SHARD-WP-0008-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "ab01fffb-61ad-416c-9f13-fdfbfd503153"
|
state_hub_task_id: "ab01fffb-61ad-416c-9f13-fdfbfd503153"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user