generated from coulomb/repo-seed
Compare commits
7 Commits
3b4f10a349
...
517cf1d282
| Author | SHA1 | Date | |
|---|---|---|---|
| 517cf1d282 | |||
| b44b2a74a4 | |||
| 24108b65aa | |||
| 6d48a1d3e6 | |||
| 9a4e00a05a | |||
| 5a77ea879c | |||
| aca9bf30f9 |
2
SCOPE.md
2
SCOPE.md
@@ -17,7 +17,7 @@ Learnings update both SCOPE and INTENT where necessary.
|
|||||||
|
|
||||||
| Layer | State |
|
| Layer | State |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| Code | Python package scaffold (`src/shard_wiki/`, smoke tests only) |
|
| 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 |
|
||||||
| 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 |
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"""shard-wiki — Git-based Markdown wiki orchestrator and federation layer.
|
"""shard-wiki — Git-based Markdown wiki orchestrator and federation layer.
|
||||||
|
|
||||||
See INTENT.md for the authoritative specification of scope and boundaries.
|
See INTENT.md for the authoritative specification of scope and boundaries, and
|
||||||
This package orchestrates wiki-shaped content across heterogeneous *shards*;
|
spec/CoreArchitectureBlueprint.md for the architecture. This package orchestrates
|
||||||
it is not itself a wiki engine.
|
wiki-shaped content across heterogeneous *shards*; it is not itself a wiki engine.
|
||||||
|
|
||||||
|
Foundation slice (SHARD-WP-0007): attach folder shard(s) to an
|
||||||
|
:class:`~shard_wiki.space.InformationSpace`, resolve a name through the union, and
|
||||||
|
read a page with layered provenance (chorus on ambiguity).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from shard_wiki.space import InformationSpace
|
||||||
|
|
||||||
__version__ = "0.0.0"
|
__version__ = "0.0.0"
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__", "InformationSpace"]
|
||||||
|
|||||||
22
src/shard_wiki/adapters/__init__.py
Normal file
22
src/shard_wiki/adapters/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""adapters/ — the shard adapter contract (bottom waist) and concrete adapters."""
|
||||||
|
|
||||||
|
from shard_wiki.adapters.conformance import (
|
||||||
|
Check,
|
||||||
|
ConformanceError,
|
||||||
|
ConformanceReport,
|
||||||
|
assert_conformant,
|
||||||
|
run_conformance,
|
||||||
|
)
|
||||||
|
from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter
|
||||||
|
from shard_wiki.adapters.folder import FolderAdapter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ShardAdapter",
|
||||||
|
"FolderAdapter",
|
||||||
|
"CONTRACT_VERSION",
|
||||||
|
"Check",
|
||||||
|
"ConformanceReport",
|
||||||
|
"ConformanceError",
|
||||||
|
"run_conformance",
|
||||||
|
"assert_conformant",
|
||||||
|
]
|
||||||
126
src/shard_wiki/adapters/conformance.py
Normal file
126
src/shard_wiki/adapters/conformance.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Adapter conformance — profiles are verified, not self-asserted (TSD §A.2, blueprint §6.6).
|
||||||
|
|
||||||
|
Capability-as-data (I-3) is only sound if a binding's *declared* profile matches its *observed*
|
||||||
|
behaviour. This battery exercises a binding and reports, check by check, whether claim == reality;
|
||||||
|
``assert_conformant`` gates registration. A lying/buggy profile fails here instead of silently
|
||||||
|
poisoning degradation decisions downstream.
|
||||||
|
|
||||||
|
This slice verifies the read path + honest absence of unclaimed verbs. Positive probes for
|
||||||
|
claimed write/diff/merge are deferred (they mutate) to a later workplan.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from shard_wiki.adapters.contract import ShardAdapter
|
||||||
|
from shard_wiki.model import NotSupported, Verb
|
||||||
|
|
||||||
|
__all__ = ["Check", "ConformanceReport", "ConformanceError", "run_conformance", "assert_conformant"]
|
||||||
|
|
||||||
|
# Optional verbs whose *absence* must be honest (calling an unclaimed one raises NotSupported).
|
||||||
|
_HONEST_ABSENCE_VERBS = (Verb.WRITE, Verb.DIFF, Verb.NOTIFY)
|
||||||
|
_PROBE = {
|
||||||
|
Verb.WRITE: lambda a: a.write("__probe__", ""),
|
||||||
|
Verb.DIFF: lambda a: a.diff("__probe__", "__probe2__"),
|
||||||
|
Verb.NOTIFY: lambda a: a.notify(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Check:
|
||||||
|
name: str
|
||||||
|
ok: bool
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConformanceReport:
|
||||||
|
adapter: str
|
||||||
|
checks: tuple[Check, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return all(c.ok for c in self.checks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failures(self) -> tuple[Check, ...]:
|
||||||
|
return tuple(c for c in self.checks if not c.ok)
|
||||||
|
|
||||||
|
def diff(self) -> str:
|
||||||
|
return "; ".join(f"{c.name}: {c.detail}" for c in self.failures) or "conformant"
|
||||||
|
|
||||||
|
|
||||||
|
class ConformanceError(Exception):
|
||||||
|
def __init__(self, report: ConformanceReport) -> None:
|
||||||
|
super().__init__(f"{report.adapter} not conformant — {report.diff()}")
|
||||||
|
self.report = report
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(fn, name: str, ok_detail: str = "") -> Check:
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except Exception as exc: # noqa: BLE001 — a check must never crash the battery
|
||||||
|
return Check(name, False, f"unexpected error: {exc!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_conformance(adapter: ShardAdapter) -> ConformanceReport:
|
||||||
|
checks: list[Check] = []
|
||||||
|
profile = None
|
||||||
|
|
||||||
|
def _profile_validates() -> Check:
|
||||||
|
nonlocal profile
|
||||||
|
profile = adapter.profile()
|
||||||
|
profile.validate()
|
||||||
|
return Check("profile-validates", True)
|
||||||
|
|
||||||
|
checks.append(_safe(_profile_validates, "profile-validates"))
|
||||||
|
if profile is None: # profile() or validate() failed; can't probe further meaningfully
|
||||||
|
return ConformanceReport(type(adapter).__name__, tuple(checks))
|
||||||
|
|
||||||
|
# READ is the capability floor.
|
||||||
|
checks.append(Check("supports-read", profile.supports(Verb.READ),
|
||||||
|
"" if profile.supports(Verb.READ) else "READ not declared"))
|
||||||
|
|
||||||
|
# READ round-trips: a declared-readable shard must actually read its own keys.
|
||||||
|
def _read_round_trips() -> Check:
|
||||||
|
keys = list(adapter.keys())
|
||||||
|
if not keys:
|
||||||
|
return Check("read-round-trips", True, "empty shard")
|
||||||
|
page = adapter.read(keys[0])
|
||||||
|
if page.identity.shard != adapter.shard_id:
|
||||||
|
return Check("read-round-trips", False,
|
||||||
|
f"page shard {page.identity.shard!r} != {adapter.shard_id!r}")
|
||||||
|
if not isinstance(page.body, str):
|
||||||
|
return Check("read-round-trips", False, "body is not text")
|
||||||
|
return Check("read-round-trips", True)
|
||||||
|
|
||||||
|
if profile.supports(Verb.READ):
|
||||||
|
checks.append(_safe(_read_round_trips, "read-round-trips"))
|
||||||
|
|
||||||
|
# Honest absence: an *unclaimed* optional verb must raise NotSupported when invoked.
|
||||||
|
for verb in _HONEST_ABSENCE_VERBS:
|
||||||
|
if profile.supports(verb):
|
||||||
|
continue # claimed → positive probe deferred (would mutate)
|
||||||
|
name = f"honest-absence:{verb.value}"
|
||||||
|
|
||||||
|
def _probe(v=verb, n=name) -> Check:
|
||||||
|
try:
|
||||||
|
_PROBE[v](adapter)
|
||||||
|
except NotSupported:
|
||||||
|
return Check(n, True)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return Check(n, False, f"raised {type(exc).__name__}, expected NotSupported")
|
||||||
|
return Check(n, False, "did not raise NotSupported though verb is unclaimed")
|
||||||
|
|
||||||
|
checks.append(_probe())
|
||||||
|
|
||||||
|
return ConformanceReport(type(adapter).__name__, tuple(checks))
|
||||||
|
|
||||||
|
|
||||||
|
def assert_conformant(adapter: ShardAdapter) -> ConformanceReport:
|
||||||
|
"""Run the battery; raise :class:`ConformanceError` if any check fails. Returns the report."""
|
||||||
|
report = run_conformance(adapter)
|
||||||
|
if not report.ok:
|
||||||
|
raise ConformanceError(report)
|
||||||
|
return report
|
||||||
52
src/shard_wiki/adapters/contract.py
Normal file
52
src/shard_wiki/adapters/contract.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""The shard adapter contract — the bottom narrow waist (CoreArchitectureBlueprint §6, TSD §A).
|
||||||
|
|
||||||
|
A backend participates by implementing :class:`ShardAdapter`. ``shard_id``, ``profile`` and
|
||||||
|
``read`` are mandatory; everything else is an optional capability that defaults to raising
|
||||||
|
:class:`~shard_wiki.model.NotSupported` — so a limited backend is honest about what it can't do
|
||||||
|
(graceful degradation, I-8) and core never assumes a capability it wasn't given (capability-as-
|
||||||
|
data, I-3). Declared profiles are verified by the conformance suite (T4), never taken on trust.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from shard_wiki.model import CapabilityProfile, NotSupported, Page
|
||||||
|
|
||||||
|
__all__ = ["ShardAdapter", "CONTRACT_VERSION"]
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "0.1"
|
||||||
|
|
||||||
|
|
||||||
|
class ShardAdapter(abc.ABC):
|
||||||
|
"""Versioned interface a backend implements to attach as a shard."""
|
||||||
|
|
||||||
|
contract_version: str = CONTRACT_VERSION
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def shard_id(self) -> str:
|
||||||
|
"""Stable id scoping every Identity this shard mints."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def profile(self) -> CapabilityProfile:
|
||||||
|
"""The (to-be-verified) capability profile of this binding."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def keys(self) -> Iterable[str]:
|
||||||
|
"""The stable page keys this shard offers (the handle half of Identity)."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def read(self, key: str) -> Page:
|
||||||
|
"""Read one page by its stable key. Raises ``KeyError`` if absent."""
|
||||||
|
|
||||||
|
# --- optional capability verbs: honest NotSupported by default ---
|
||||||
|
def write(self, key: str, body: str) -> Page: # noqa: ARG002
|
||||||
|
raise NotSupported(f"{type(self).__name__} does not support write")
|
||||||
|
|
||||||
|
def diff(self, key: str, other: str) -> str: # noqa: ARG002
|
||||||
|
raise NotSupported(f"{type(self).__name__} does not support diff")
|
||||||
|
|
||||||
|
def notify(self):
|
||||||
|
raise NotSupported(f"{type(self).__name__} does not support notify")
|
||||||
91
src/shard_wiki/adapters/folder.py
Normal file
91
src/shard_wiki/adapters/folder.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""FolderAdapter — a read-only file-store shard over a directory of Markdown.
|
||||||
|
|
||||||
|
The home-case substrate: a plain folder of ``.md`` files. The relative path (sans extension,
|
||||||
|
``/``-separated) is the stable page **key**; the file is the page **body**; mtime gives a
|
||||||
|
freshness stamp. Read-only in this slice (overlay/write-through come later); declared profile
|
||||||
|
reflects exactly that (read-only, file-store, path addressing, no native history/query).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from shard_wiki.adapters.contract import ShardAdapter
|
||||||
|
from shard_wiki.model import (
|
||||||
|
AccessGrant,
|
||||||
|
Addressing,
|
||||||
|
AttachmentMode,
|
||||||
|
CapabilityProfile,
|
||||||
|
ContentOpacity,
|
||||||
|
History,
|
||||||
|
Identity,
|
||||||
|
MergeModel,
|
||||||
|
NativeQuery,
|
||||||
|
OperationalEnvelope,
|
||||||
|
Page,
|
||||||
|
Placement,
|
||||||
|
Substrate,
|
||||||
|
Translation,
|
||||||
|
Verb,
|
||||||
|
WriteGranularity,
|
||||||
|
)
|
||||||
|
from shard_wiki.provenance import Liveness, ProvenanceEnvelope, Staleness
|
||||||
|
|
||||||
|
__all__ = ["FolderAdapter"]
|
||||||
|
|
||||||
|
|
||||||
|
class FolderAdapter(ShardAdapter):
|
||||||
|
def __init__(self, shard_id: str, root: str | Path) -> None:
|
||||||
|
self._shard_id = shard_id
|
||||||
|
self._root = Path(root)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shard_id(self) -> str:
|
||||||
|
return self._shard_id
|
||||||
|
|
||||||
|
def profile(self) -> CapabilityProfile:
|
||||||
|
return CapabilityProfile(
|
||||||
|
substrate=Substrate.FILES,
|
||||||
|
attachment_mode=AttachmentMode.FILE_STORE,
|
||||||
|
write_granularity=WriteGranularity.NONE,
|
||||||
|
content_opacity=ContentOpacity.TRANSPARENT,
|
||||||
|
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||||
|
access_grant=AccessGrant.OPEN,
|
||||||
|
liveness=Liveness.STATIC,
|
||||||
|
history=History.NONE,
|
||||||
|
merge_model=MergeModel.NONE,
|
||||||
|
addressing=Addressing.PATH,
|
||||||
|
native_query=NativeQuery.NONE,
|
||||||
|
translation=Translation.NATIVE,
|
||||||
|
supported_verbs=frozenset({Verb.READ}),
|
||||||
|
).validate()
|
||||||
|
|
||||||
|
def _path_for(self, key: str) -> Path:
|
||||||
|
return self._root / f"{key}.md"
|
||||||
|
|
||||||
|
def keys(self) -> Iterable[str]:
|
||||||
|
for p in sorted(self._root.rglob("*.md")):
|
||||||
|
yield p.relative_to(self._root).with_suffix("").as_posix()
|
||||||
|
|
||||||
|
def read(self, key: str) -> Page:
|
||||||
|
path = self._path_for(key)
|
||||||
|
if not path.is_file():
|
||||||
|
raise KeyError(key)
|
||||||
|
body = path.read_text(encoding="utf-8")
|
||||||
|
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
||||||
|
envelope = ProvenanceEnvelope(
|
||||||
|
source_shard=self._shard_id,
|
||||||
|
liveness=Liveness.STATIC,
|
||||||
|
staleness=Staleness.FRESH,
|
||||||
|
source_rev=mtime.isoformat(),
|
||||||
|
observed_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
rel = path.relative_to(self._root).as_posix()
|
||||||
|
return Page(
|
||||||
|
identity=Identity(self._shard_id, key),
|
||||||
|
body=body,
|
||||||
|
envelope=envelope,
|
||||||
|
placements=(Placement(self._shard_id, rel),),
|
||||||
|
)
|
||||||
10
src/shard_wiki/coordination/__init__.py
Normal file
10
src/shard_wiki/coordination/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""coordination/ — the event-sourced decision log (L3, coordination-canonical state)."""
|
||||||
|
|
||||||
|
from shard_wiki.coordination.decision_log import (
|
||||||
|
CoordinationState,
|
||||||
|
DecisionEvent,
|
||||||
|
DecisionLog,
|
||||||
|
EventType,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["DecisionLog", "DecisionEvent", "EventType", "CoordinationState"]
|
||||||
124
src/shard_wiki/coordination/decision_log.py
Normal file
124
src/shard_wiki/coordination/decision_log.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""The event-sourced coordination decision log — the keystone (CoreArchitectureBlueprint §8.1).
|
||||||
|
|
||||||
|
Coordination-canonical state (overlays, equivalence bindings, aliases, merges, forks) is an
|
||||||
|
**append-only decision log**, not a mutable file; the queryable *current* state is a **derived
|
||||||
|
fold** of the log (tier-3 disposable). The log is **totally ordered per space** via a single
|
||||||
|
**append authority** — here an in-process counter; a git-backed, lease-held authority is a later
|
||||||
|
binding. That total order is what gives read-your-writes across readers (§8.6).
|
||||||
|
|
||||||
|
`derived = f(canonical)`: :class:`CoordinationState` is always reproducible by replaying the log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
__all__ = ["EventType", "DecisionEvent", "CoordinationState", "DecisionLog"]
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
OVERLAY_CREATED = "overlay-created"
|
||||||
|
BINDING_MADE = "binding-made"
|
||||||
|
ALIAS_SET = "alias-set"
|
||||||
|
MERGE_DECIDED = "merge-decided"
|
||||||
|
PAGE_FORKED = "page-forked"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DecisionEvent:
|
||||||
|
"""One immutable, ordered decision. ``seq`` is the per-space total order."""
|
||||||
|
|
||||||
|
seq: int
|
||||||
|
space: str
|
||||||
|
type: EventType
|
||||||
|
payload: Mapping[str, Any]
|
||||||
|
actor: str | None = None
|
||||||
|
timestamp: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CoordinationState:
|
||||||
|
"""The derived fold of a space's log: current aliases + equivalence groups + open overlays.
|
||||||
|
|
||||||
|
Disposable (tier-3): always recomputable from the log via :meth:`DecisionLog.fold`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
aliases: Mapping[str, str]
|
||||||
|
equivalence_groups: tuple[frozenset[str], ...]
|
||||||
|
open_overlays: Mapping[str, Mapping[str, Any]]
|
||||||
|
|
||||||
|
def resolve_alias(self, name: str) -> str | None:
|
||||||
|
return self.aliases.get(name)
|
||||||
|
|
||||||
|
def equivalent_to(self, identity: str) -> frozenset[str]:
|
||||||
|
"""All identities equivalent to ``identity`` (including itself if bound), else just it."""
|
||||||
|
for group in self.equivalence_groups:
|
||||||
|
if identity in group:
|
||||||
|
return group
|
||||||
|
return frozenset({identity})
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionLog:
|
||||||
|
"""In-memory append-only log, totally ordered per space (the append authority for a process).
|
||||||
|
|
||||||
|
A later binding swaps the storage for git + a per-space lease without changing this API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._events: dict[str, list[DecisionEvent]] = {}
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
space: str,
|
||||||
|
type: EventType,
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
actor: str | None = None,
|
||||||
|
) -> DecisionEvent:
|
||||||
|
seq = len(self._events.get(space, ()))
|
||||||
|
event = DecisionEvent(seq=seq, space=space, type=type, payload=dict(payload), actor=actor)
|
||||||
|
self._events.setdefault(space, []).append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def events(self, space: str) -> tuple[DecisionEvent, ...]:
|
||||||
|
"""The space's events in append (total) order. Read-your-writes: a just-appended event
|
||||||
|
is present immediately."""
|
||||||
|
return tuple(self._events.get(space, ()))
|
||||||
|
|
||||||
|
def fold(self, space: str) -> CoordinationState:
|
||||||
|
"""Replay the log into current coordination state (derived = f(log))."""
|
||||||
|
aliases: dict[str, str] = {}
|
||||||
|
overlays: dict[str, dict[str, Any]] = {}
|
||||||
|
groups: list[set[str]] = []
|
||||||
|
|
||||||
|
for event in self.events(space):
|
||||||
|
if event.type is EventType.ALIAS_SET:
|
||||||
|
aliases[event.payload["alias"]] = event.payload["target"]
|
||||||
|
elif event.type is EventType.BINDING_MADE:
|
||||||
|
_merge_group(groups, {str(m) for m in event.payload["members"]})
|
||||||
|
elif event.type is EventType.OVERLAY_CREATED:
|
||||||
|
overlays[event.payload["overlay_id"]] = dict(event.payload)
|
||||||
|
elif event.type is EventType.MERGE_DECIDED:
|
||||||
|
# A merge resolution may collapse an overlay; minimal handling for the slice.
|
||||||
|
overlays.pop(event.payload.get("overlay_id", ""), None)
|
||||||
|
elif event.type is EventType.PAGE_FORKED:
|
||||||
|
_merge_group(groups, {str(event.payload["source"]), str(event.payload["fork"])})
|
||||||
|
|
||||||
|
return CoordinationState(
|
||||||
|
aliases=MappingProxyType(dict(aliases)),
|
||||||
|
equivalence_groups=tuple(frozenset(g) for g in groups),
|
||||||
|
open_overlays=MappingProxyType({k: MappingProxyType(v) for k, v in overlays.items()}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_group(groups: list[set[str]], members: set[str]) -> None:
|
||||||
|
"""Union-merge ``members`` into ``groups`` (any existing group sharing a member absorbs it)."""
|
||||||
|
touching = [g for g in groups if g & members]
|
||||||
|
for g in touching:
|
||||||
|
groups.remove(g)
|
||||||
|
members |= g
|
||||||
|
groups.append(members)
|
||||||
47
src/shard_wiki/model/__init__.py
Normal file
47
src/shard_wiki/model/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""model/ — the backend-neutral page model and capability types (the top waist).
|
||||||
|
|
||||||
|
Imports only the ``provenance`` leaf; nothing backend-specific lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shard_wiki.model.capability import (
|
||||||
|
AccessGrant,
|
||||||
|
Addressing,
|
||||||
|
AttachmentMode,
|
||||||
|
CapabilityProfile,
|
||||||
|
ContentOpacity,
|
||||||
|
History,
|
||||||
|
MergeModel,
|
||||||
|
NativeQuery,
|
||||||
|
NotSupported,
|
||||||
|
OperationalEnvelope,
|
||||||
|
ProfileError,
|
||||||
|
Substrate,
|
||||||
|
Translation,
|
||||||
|
Verb,
|
||||||
|
WriteGranularity,
|
||||||
|
)
|
||||||
|
from shard_wiki.model.identity import Identity, Placement, Span
|
||||||
|
from shard_wiki.model.page import Page, PageShape
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Identity",
|
||||||
|
"Placement",
|
||||||
|
"Span",
|
||||||
|
"Page",
|
||||||
|
"PageShape",
|
||||||
|
"CapabilityProfile",
|
||||||
|
"Verb",
|
||||||
|
"Substrate",
|
||||||
|
"AttachmentMode",
|
||||||
|
"WriteGranularity",
|
||||||
|
"ContentOpacity",
|
||||||
|
"OperationalEnvelope",
|
||||||
|
"AccessGrant",
|
||||||
|
"History",
|
||||||
|
"MergeModel",
|
||||||
|
"Addressing",
|
||||||
|
"NativeQuery",
|
||||||
|
"Translation",
|
||||||
|
"ProfileError",
|
||||||
|
"NotSupported",
|
||||||
|
]
|
||||||
220
src/shard_wiki/model/capability.py
Normal file
220
src/shard_wiki/model/capability.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""Capability profile — capability-as-data (CoreArchitectureBlueprint §6, I-3).
|
||||||
|
|
||||||
|
A binding's abilities are a *position on each capability spectrum*, not a boolean checklist.
|
||||||
|
Descriptively there are fifteen spectra (§6.1); operationally they reduce to a small
|
||||||
|
**orthogonal core** plus **implied** axes, with **implication rules that forbid impossible
|
||||||
|
profiles** (§6.5). ``CapabilityProfile.validate()`` enforces those rules; a profile that
|
||||||
|
violates one is rejected (the structural half of "capability-as-data is sound" — the behavioural
|
||||||
|
half is the conformance suite, T4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from shard_wiki.provenance import Liveness
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Verb",
|
||||||
|
"Substrate",
|
||||||
|
"AttachmentMode",
|
||||||
|
"WriteGranularity",
|
||||||
|
"ContentOpacity",
|
||||||
|
"OperationalEnvelope",
|
||||||
|
"AccessGrant",
|
||||||
|
"History",
|
||||||
|
"MergeModel",
|
||||||
|
"Addressing",
|
||||||
|
"NativeQuery",
|
||||||
|
"Translation",
|
||||||
|
"CapabilityProfile",
|
||||||
|
"ProfileError",
|
||||||
|
"NotSupported",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTRACT_VERSION = "0.1"
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileError(ValueError):
|
||||||
|
"""Raised when a capability profile is internally impossible (violates §6.5 rules)."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotSupported(Exception):
|
||||||
|
"""Raised by an adapter when an operation verb is not in its capability profile."""
|
||||||
|
|
||||||
|
|
||||||
|
class Verb(Enum):
|
||||||
|
READ = "read"
|
||||||
|
WRITE = "write"
|
||||||
|
DIFF = "diff"
|
||||||
|
MERGE = "merge"
|
||||||
|
LOCK = "lock"
|
||||||
|
VERSION = "version"
|
||||||
|
PUBLISH = "publish"
|
||||||
|
NOTIFY = "notify"
|
||||||
|
TRANSCLUDE_SOURCE = "transclude-source"
|
||||||
|
TRANSLATE_SYNTAX = "translate-syntax"
|
||||||
|
STRUCTURED_PAYLOAD = "structured-payload"
|
||||||
|
DERIVE_PROJECTION = "derive-projection" # gated, off by default (T18)
|
||||||
|
EXECUTE = "execute" # gated, off by default (T18)
|
||||||
|
|
||||||
|
|
||||||
|
# --- orthogonal core axes (§6.5a) ---
|
||||||
|
class Substrate(Enum):
|
||||||
|
GIT = "git"
|
||||||
|
FILES = "files"
|
||||||
|
RELATIONAL_DB = "relational-db"
|
||||||
|
EXTERNAL_API = "external-api"
|
||||||
|
CRDT = "crdt"
|
||||||
|
|
||||||
|
|
||||||
|
class WriteGranularity(Enum):
|
||||||
|
NONE = "none" # read-only
|
||||||
|
WHOLE_FILE = "whole-file"
|
||||||
|
SECTION = "section"
|
||||||
|
PER_PAGE = "per-page"
|
||||||
|
PER_BLOCK = "per-block"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentOpacity(Enum):
|
||||||
|
TRANSPARENT = "transparent"
|
||||||
|
STRUCTURED_REEVALUABLE = "structured-re-evaluable"
|
||||||
|
ENCRYPTED = "encrypted"
|
||||||
|
PER_ITEM = "per-item"
|
||||||
|
PROPRIETARY_LOSSY = "proprietary-lossy"
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalEnvelope(Enum):
|
||||||
|
LOCAL_UNBOUNDED = "local-unbounded"
|
||||||
|
REALTIME = "realtime"
|
||||||
|
RATE_LIMITED = "rate-limited"
|
||||||
|
|
||||||
|
|
||||||
|
class AccessGrant(Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
TOKEN = "token"
|
||||||
|
OAUTH_SCOPED = "oauth-scoped"
|
||||||
|
P2P_KEY = "p2p-key"
|
||||||
|
ENTERPRISE_ACL = "enterprise-acl"
|
||||||
|
|
||||||
|
|
||||||
|
# --- implied axes (§6.5b): may be derived from the core, validated for consistency ---
|
||||||
|
class History(Enum):
|
||||||
|
NONE = "none"
|
||||||
|
INTERNAL_ONLY = "internal-only"
|
||||||
|
CRDT_LOG = "crdt-log"
|
||||||
|
OPEN_FILE = "open-file"
|
||||||
|
GIT_NATIVE = "git-native"
|
||||||
|
|
||||||
|
|
||||||
|
class MergeModel(Enum):
|
||||||
|
NONE = "none"
|
||||||
|
GIT_TEXT = "git-text"
|
||||||
|
KEEP_BOTH = "keep-both"
|
||||||
|
NATIVE_CRDT = "native-crdt"
|
||||||
|
COEXIST_RANK = "coexist-rank"
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentMode(Enum):
|
||||||
|
# NB: there is deliberately no IMAGE mode — an image/live-memory blob is never an attach
|
||||||
|
# target (I-12). It participates only via export→files.
|
||||||
|
FILE_STORE = "file-store"
|
||||||
|
GIT_IS_STORE = "git-is-store"
|
||||||
|
IN_ENGINE_HOST = "in-engine-host"
|
||||||
|
LOCAL_REST = "local-rest"
|
||||||
|
EXTERNAL_API = "external-api"
|
||||||
|
DIRECT_DB = "direct-db"
|
||||||
|
CRDT_REPLICA = "crdt-replica"
|
||||||
|
P2P = "p2p"
|
||||||
|
|
||||||
|
|
||||||
|
class Addressing(Enum):
|
||||||
|
NONE = "none"
|
||||||
|
PATH = "path"
|
||||||
|
PAGE_ID = "page-id"
|
||||||
|
SPAN = "span"
|
||||||
|
BLOCK_ID = "block-id"
|
||||||
|
STORE_UUID = "store-uuid"
|
||||||
|
TUMBLER = "tumbler"
|
||||||
|
|
||||||
|
|
||||||
|
class NativeQuery(Enum):
|
||||||
|
NONE = "none"
|
||||||
|
TEXT = "text"
|
||||||
|
DERIVED_INDEX = "derived-index"
|
||||||
|
DATALOG_GRAPH = "datalog-graph"
|
||||||
|
DB_QUERY = "db-query"
|
||||||
|
SPARQL = "sparql"
|
||||||
|
|
||||||
|
|
||||||
|
class Translation(Enum):
|
||||||
|
NATIVE = "native"
|
||||||
|
LOSSLESS = "lossless"
|
||||||
|
LOSSY_WITH_REPORT = "lossy-with-report"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CapabilityProfile:
|
||||||
|
"""A verified-at-registration position on each capability axis, plus supported verbs."""
|
||||||
|
|
||||||
|
# orthogonal core
|
||||||
|
substrate: Substrate
|
||||||
|
attachment_mode: AttachmentMode
|
||||||
|
write_granularity: WriteGranularity
|
||||||
|
content_opacity: ContentOpacity
|
||||||
|
operational_envelope: OperationalEnvelope
|
||||||
|
access_grant: AccessGrant
|
||||||
|
liveness: Liveness # computational/liveness axis (15th)
|
||||||
|
# implied
|
||||||
|
history: History
|
||||||
|
merge_model: MergeModel
|
||||||
|
addressing: Addressing
|
||||||
|
native_query: NativeQuery
|
||||||
|
translation: Translation
|
||||||
|
supported_verbs: frozenset[Verb] = field(default_factory=frozenset)
|
||||||
|
contract_version: str = CONTRACT_VERSION
|
||||||
|
|
||||||
|
def supports(self, verb: Verb) -> bool:
|
||||||
|
return verb in self.supported_verbs
|
||||||
|
|
||||||
|
def validate(self) -> CapabilityProfile:
|
||||||
|
"""Apply the §6.5 implication rules; raise :class:`ProfileError` on an impossible
|
||||||
|
combination. Returns ``self`` so it can be used fluently at construction."""
|
||||||
|
rules: list[tuple[bool, str]] = [
|
||||||
|
(
|
||||||
|
Verb.READ in self.supported_verbs,
|
||||||
|
"every shard must support READ (the capability floor)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.attachment_mode is not AttachmentMode.GIT_IS_STORE
|
||||||
|
or (self.substrate is Substrate.GIT and self.history is History.GIT_NATIVE),
|
||||||
|
"git-is-store ⟹ substrate=git ∧ history=git-native",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.content_opacity is not ContentOpacity.ENCRYPTED
|
||||||
|
or self.native_query is NativeQuery.NONE,
|
||||||
|
"encrypted opacity ⟹ native-query=none",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.merge_model is not MergeModel.NATIVE_CRDT
|
||||||
|
or (
|
||||||
|
self.history is History.CRDT_LOG
|
||||||
|
and self.operational_envelope is OperationalEnvelope.REALTIME
|
||||||
|
),
|
||||||
|
"native-crdt merge ⟹ history=crdt-log ∧ envelope=realtime",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(self.write_granularity is WriteGranularity.NONE)
|
||||||
|
== (Verb.WRITE not in self.supported_verbs),
|
||||||
|
"write-granularity=none ⟺ WRITE not supported (read-only consistency)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Verb.MERGE not in self.supported_verbs or self.merge_model is not MergeModel.NONE,
|
||||||
|
"MERGE verb ⟹ merge-model ≠ none",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
broken = [msg for ok, msg in rules if not ok]
|
||||||
|
if broken:
|
||||||
|
raise ProfileError("; ".join(broken))
|
||||||
|
return self
|
||||||
60
src/shard_wiki/model/identity.py
Normal file
60
src/shard_wiki/model/identity.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Identity, Placement, Span — three distinct concepts (CoreArchitectureBlueprint §7.2).
|
||||||
|
|
||||||
|
- **Identity** is a *stable handle* assigned/minted by a shard; it survives edits. It is NEVER
|
||||||
|
derived from content (a content fingerprint identifies a *version*, not a *page*).
|
||||||
|
- **Placement** is *where* an identity sits (path in a shard); one identity may have many
|
||||||
|
placements (a DAG). Placement can change without changing identity.
|
||||||
|
- **Span** is a sub-page address within an identity, optionally carrying a provenance delta.
|
||||||
|
|
||||||
|
Equivalence (detecting that two *distinct* identities hold the same content) is a separate
|
||||||
|
mechanism and lives in ``union/`` — not here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from shard_wiki.provenance import SpanProvenanceDelta
|
||||||
|
|
||||||
|
__all__ = ["Identity", "Placement", "Span"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Identity:
|
||||||
|
"""A shard-scoped, stable page handle. Value-equal; stable across content edits.
|
||||||
|
|
||||||
|
``shard`` scopes the handle so native ids survive projection and never collide across
|
||||||
|
shards. ``key`` is the backend's stable handle (page name / native uid) — not a fingerprint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
shard: str
|
||||||
|
key: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.shard}:{self.key}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Placement:
|
||||||
|
"""Where an identity sits: a path within a shard. Mutable over an identity's life."""
|
||||||
|
|
||||||
|
shard: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.shard}/{self.path}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Span:
|
||||||
|
"""A sub-page address within a page identity, with an optional provenance delta.
|
||||||
|
|
||||||
|
``ref`` is a native span id where the backend mints one (Roam ``:block/uid``, Logseq
|
||||||
|
``id::``), else a positional/fingerprint address. ``delta`` overrides the page envelope
|
||||||
|
only where this span genuinely differs (effective-vs-own, blueprint §7.3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
ref: str
|
||||||
|
start: int | None = None
|
||||||
|
end: int | None = None
|
||||||
|
delta: SpanProvenanceDelta | None = None
|
||||||
43
src/shard_wiki/model/page.py
Normal file
43
src/shard_wiki/model/page.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""The backend-neutral wiki page model — the top narrow waist (CoreArchitectureBlueprint §7).
|
||||||
|
|
||||||
|
Markdown-first but stretchable: every shape reduces to
|
||||||
|
``(content|source, structure, provenance envelope, [derivation rule])``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from shard_wiki.model.identity import Identity, Placement, Span
|
||||||
|
from shard_wiki.provenance import ProvenanceEnvelope
|
||||||
|
|
||||||
|
__all__ = ["PageShape", "Page"]
|
||||||
|
|
||||||
|
|
||||||
|
class PageShape(Enum):
|
||||||
|
"""The page-model shapes (blueprint §7.1). The slice handles PROSE; the rest are declared
|
||||||
|
so adapters can tag content faithfully (union without erasure)."""
|
||||||
|
|
||||||
|
PROSE = "prose"
|
||||||
|
TYPED_RECORD = "typed-record"
|
||||||
|
TYPED_GRAPH = "typed-graph"
|
||||||
|
INLINE_EMBEDDED = "inline-embedded"
|
||||||
|
NON_MARKDOWN_ASSET = "non-markdown-asset"
|
||||||
|
ONE_SOURCE_MANY_PROJECTIONS = "one-source-many-projections"
|
||||||
|
NOTEBOOK = "notebook"
|
||||||
|
PROGRAM_AS_PAGE = "program-as-page"
|
||||||
|
LIVE_TEMPORAL = "live-temporal"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Page:
|
||||||
|
"""A page in the union: a stable identity, content, the page-level provenance envelope,
|
||||||
|
its placements, and its spans (whose deltas layer over the page envelope, §7.3)."""
|
||||||
|
|
||||||
|
identity: Identity
|
||||||
|
body: str
|
||||||
|
envelope: ProvenanceEnvelope
|
||||||
|
shape: PageShape = PageShape.PROSE
|
||||||
|
placements: tuple[Placement, ...] = ()
|
||||||
|
spans: tuple[Span, ...] = field(default_factory=tuple)
|
||||||
36
src/shard_wiki/policy/__init__.py
Normal file
36
src/shard_wiki/policy/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""policy/ — the configurable policy surface, a dependency-free leaf (blueprint §10, §11).
|
||||||
|
|
||||||
|
Mechanism over policy (I-7): core mechanisms read policy *choices* from here; they never
|
||||||
|
hard-code one. This leaf holds only the presets + a pure ``resolve``-style selector. Mechanism
|
||||||
|
(how a choice is honoured) lives in ``coordination``/``union``/``projection``, never here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
__all__ = ["CanonicalSource", "Policy", "DEFAULT_POLICY"]
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalSource(Enum):
|
||||||
|
"""Resolution policy over a divergent/equivalent set (FederationArchitecture T9). Detection
|
||||||
|
is core; this is only the *resolution* choice."""
|
||||||
|
|
||||||
|
CHORUS = "chorus" # default: present all versions, none privileged
|
||||||
|
DESIGNATED_CANONICAL = "designated-canonical"
|
||||||
|
GIT_MERGE = "git-merge"
|
||||||
|
VOTE = "vote"
|
||||||
|
OVERLAY_ONLY = "overlay-only"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Policy:
|
||||||
|
"""A space's policy choices. Extended as the policy surface grows (freshness, compaction,
|
||||||
|
execution, tenant mapping — blueprint §10); the slice needs canonical-source + designation."""
|
||||||
|
|
||||||
|
canonical_source: CanonicalSource = CanonicalSource.CHORUS
|
||||||
|
designated_shard: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POLICY = Policy()
|
||||||
116
src/shard_wiki/provenance/__init__.py
Normal file
116
src/shard_wiki/provenance/__init__.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Provenance — the cross-cutting leaf rail (CoreArchitectureBlueprint §7.3, §11).
|
||||||
|
|
||||||
|
Every artifact in the union carries a :class:`ProvenanceEnvelope`. To keep per-span cost near
|
||||||
|
zero when provenance is uniform, envelopes are **layered**: a page-level envelope plus optional
|
||||||
|
per-span deltas. The *effective* provenance of a span is ``page_envelope ⊕ span_delta`` — a span
|
||||||
|
with no delta inherits the page envelope at zero cost ("effective-vs-own", the same pattern the
|
||||||
|
page model uses for computed metadata).
|
||||||
|
|
||||||
|
This module is a **dependency-free leaf**: it imports nothing else in the package and contains
|
||||||
|
only stable data types plus the pure ``effective`` composition. Mechanism never lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Liveness",
|
||||||
|
"Staleness",
|
||||||
|
"OverlayState",
|
||||||
|
"ProvenanceEnvelope",
|
||||||
|
"SpanProvenanceDelta",
|
||||||
|
"effective",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Liveness(Enum):
|
||||||
|
"""Where a view sits on the live↔snapshot axis (blueprint §8, T18)."""
|
||||||
|
|
||||||
|
STATIC = "static"
|
||||||
|
CAPTURED_SNAPSHOT = "captured-snapshot"
|
||||||
|
LIVE_OVER_FILES = "live-over-files"
|
||||||
|
VIEW_TIME = "view-time"
|
||||||
|
IRREDUCIBLY_LIVE = "irreducibly-live"
|
||||||
|
|
||||||
|
|
||||||
|
class Staleness(Enum):
|
||||||
|
"""Freshness state of cached/projected content (blueprint §8.8). ``UNAVAILABLE`` is the
|
||||||
|
dead-shard state (FederationRequirements ADR-04)."""
|
||||||
|
|
||||||
|
LIVE = "live"
|
||||||
|
FRESH = "fresh"
|
||||||
|
STALE = "stale"
|
||||||
|
UNAVAILABLE = "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
class OverlayState(Enum):
|
||||||
|
"""Overlay lifecycle state of an artifact (FederationRequirements ADR-05)."""
|
||||||
|
|
||||||
|
NONE = "none"
|
||||||
|
DRAFT = "draft"
|
||||||
|
PATCH_PENDING = "patch-pending"
|
||||||
|
APPLIED = "applied"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProvenanceEnvelope:
|
||||||
|
"""The metadata every page/span carries — union without erasure (INTENT I-4).
|
||||||
|
|
||||||
|
This is the *page-level* (or fully-resolved *effective*) form. Per-span overrides are
|
||||||
|
expressed as a :class:`SpanProvenanceDelta` and composed via :func:`effective`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_shard: str
|
||||||
|
liveness: Liveness = Liveness.STATIC
|
||||||
|
staleness: Staleness = Staleness.FRESH
|
||||||
|
overlay_state: OverlayState = OverlayState.NONE
|
||||||
|
source_rev: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
authz_context: str | None = None
|
||||||
|
divergence: tuple[str, ...] = ()
|
||||||
|
lineage: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SpanProvenanceDelta:
|
||||||
|
"""A per-span override of a page envelope. Every field is optional; ``None`` (or, for
|
||||||
|
``divergence``, the sentinel default) means *inherit the page-level value*. A span with an
|
||||||
|
all-default delta — or no delta at all — costs nothing and resolves to the page envelope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_shard: str | None = None
|
||||||
|
liveness: Liveness | None = None
|
||||||
|
staleness: Staleness | None = None
|
||||||
|
overlay_state: OverlayState | None = None
|
||||||
|
source_rev: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
authz_context: str | None = None
|
||||||
|
# Sentinel: ``None`` means inherit; an explicit (possibly empty) tuple overrides.
|
||||||
|
divergence: tuple[str, ...] | None = None
|
||||||
|
lineage: str | None = None
|
||||||
|
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""True when the delta overrides nothing (the common, zero-cost case)."""
|
||||||
|
return all(getattr(self, f.name) is None for f in dataclasses.fields(self))
|
||||||
|
|
||||||
|
|
||||||
|
def effective(
|
||||||
|
base: ProvenanceEnvelope, delta: SpanProvenanceDelta | None = None
|
||||||
|
) -> ProvenanceEnvelope:
|
||||||
|
"""Compose a page envelope with an optional span delta → the span's effective provenance.
|
||||||
|
|
||||||
|
``effective(base, None)`` and ``effective(base, <empty delta>)`` both return ``base``
|
||||||
|
unchanged (zero-cost inheritance). Any non-``None`` delta field overrides ``base``.
|
||||||
|
"""
|
||||||
|
if delta is None or delta.is_empty():
|
||||||
|
return base
|
||||||
|
overrides = {
|
||||||
|
f.name: value
|
||||||
|
for f in dataclasses.fields(delta)
|
||||||
|
if (value := getattr(delta, f.name)) is not None
|
||||||
|
}
|
||||||
|
return dataclasses.replace(base, **overrides)
|
||||||
42
src/shard_wiki/space.py
Normal file
42
src/shard_wiki/space.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""InformationSpace — the thin orchestrator entry tying the slice together (blueprint §3 L6).
|
||||||
|
|
||||||
|
A root entity / information space: shards attach to it (conformance-gated, TSD §A.2), a
|
||||||
|
coordination decision log records its canonical decisions, and a derived union resolves names to
|
||||||
|
pages. This is the L6 consumer surface for the foundation slice (attach → resolve → read);
|
||||||
|
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.model import Page
|
||||||
|
from shard_wiki.policy import DEFAULT_POLICY, Policy
|
||||||
|
from shard_wiki.union import Resolution, UnionGraph
|
||||||
|
|
||||||
|
__all__ = ["InformationSpace"]
|
||||||
|
|
||||||
|
|
||||||
|
class InformationSpace:
|
||||||
|
def __init__(self, space_id: str, policy: Policy = DEFAULT_POLICY) -> None:
|
||||||
|
self.space_id = space_id
|
||||||
|
self.log = DecisionLog()
|
||||||
|
self.union = UnionGraph(space_id, log=self.log, policy=policy)
|
||||||
|
|
||||||
|
def attach(self, adapter: ShardAdapter) -> None:
|
||||||
|
"""Attach a shard — only if it passes conformance (verified profile, I-3/§6.6)."""
|
||||||
|
assert_conformant(adapter)
|
||||||
|
self.union.attach(adapter)
|
||||||
|
|
||||||
|
def alias(self, name: str, target: str, actor: str | None = None) -> None:
|
||||||
|
"""Record a coordination-canonical alias (``name`` → ``"shard:key"``) in the log."""
|
||||||
|
self.log.append(
|
||||||
|
self.space_id, EventType.ALIAS_SET, {"alias": name, "target": target}, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve(self, name: str) -> Resolution:
|
||||||
|
return self.union.resolve(name)
|
||||||
|
|
||||||
|
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()
|
||||||
8
src/shard_wiki/union/__init__.py
Normal file
8
src/shard_wiki/union/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""union/ — the derived-tier union (resolution, equivalence, projection read path).
|
||||||
|
|
||||||
|
Disposable/recomputable (I-2); imports down only and is imported by nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shard_wiki.union.resolver import Resolution, ResolutionKind, UnionGraph
|
||||||
|
|
||||||
|
__all__ = ["UnionGraph", "Resolution", "ResolutionKind"]
|
||||||
119
src/shard_wiki/union/resolver.py
Normal file
119
src/shard_wiki/union/resolver.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Union resolution — the derived-tier read path (CoreArchitectureBlueprint §8.4, ADR-01).
|
||||||
|
|
||||||
|
A minimal :class:`UnionGraph` over ≥1 attached shard plus the decision-log fold. ``resolve``
|
||||||
|
keys on **identity** (FederationRequirements ADR-01): alias redirect → exact union lookup →
|
||||||
|
equivalence chorus → red-link. Ambiguity returns a **chorus set** (union without erasure, I-4):
|
||||||
|
divergent peers are recorded in each page's provenance envelope rather than silently dropped.
|
||||||
|
|
||||||
|
This is derived/disposable: it reads shards + the log fold; it stores nothing canonical. Per the
|
||||||
|
dependency rule it may import down (model/adapters/coordination/policy/provenance) and is
|
||||||
|
imported by nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
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.policy import DEFAULT_POLICY, CanonicalSource, Policy
|
||||||
|
|
||||||
|
__all__ = ["ResolutionKind", "Resolution", "UnionGraph"]
|
||||||
|
|
||||||
|
|
||||||
|
class ResolutionKind(Enum):
|
||||||
|
SINGLE = "single"
|
||||||
|
CHORUS = "chorus"
|
||||||
|
RED_LINK = "red-link"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Resolution:
|
||||||
|
name: str
|
||||||
|
kind: ResolutionKind
|
||||||
|
pages: tuple[Page, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_red_link(self) -> bool:
|
||||||
|
return self.kind is ResolutionKind.RED_LINK
|
||||||
|
|
||||||
|
def single(self) -> Page:
|
||||||
|
"""The one page (SINGLE), or the canonical pick of a CHORUS (first), else KeyError."""
|
||||||
|
if not self.pages:
|
||||||
|
raise KeyError(self.name)
|
||||||
|
return self.pages[0]
|
||||||
|
|
||||||
|
|
||||||
|
class UnionGraph:
|
||||||
|
"""Composes attached shards + the coordination-log fold into a resolvable union."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
space: str,
|
||||||
|
log: DecisionLog | None = None,
|
||||||
|
policy: Policy = DEFAULT_POLICY,
|
||||||
|
) -> None:
|
||||||
|
self.space = space
|
||||||
|
self.log = log or DecisionLog()
|
||||||
|
self.policy = policy
|
||||||
|
self._shards: list[ShardAdapter] = []
|
||||||
|
|
||||||
|
def attach(self, adapter: ShardAdapter) -> None:
|
||||||
|
self._shards.append(adapter)
|
||||||
|
|
||||||
|
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]:
|
||||||
|
pages: list[Page] = []
|
||||||
|
for shard in self._shards:
|
||||||
|
try:
|
||||||
|
pages.append(shard.read(key))
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
return pages
|
||||||
|
|
||||||
|
def resolve(self, name: str) -> Resolution:
|
||||||
|
# 1. alias redirect (coordination-canonical, via the log fold)
|
||||||
|
state = self.log.fold(self.space)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
if not pages:
|
||||||
|
return Resolution(name, ResolutionKind.RED_LINK)
|
||||||
|
if len(pages) == 1:
|
||||||
|
return Resolution(name, ResolutionKind.SINGLE, (pages[0],))
|
||||||
|
|
||||||
|
# ambiguity → chorus, with divergence recorded (never erased, I-4)
|
||||||
|
ordered = self._order_for_policy(pages)
|
||||||
|
marked = tuple(_with_divergence(p, ordered) for p in ordered)
|
||||||
|
return Resolution(name, ResolutionKind.CHORUS, marked)
|
||||||
|
|
||||||
|
def _order_for_policy(self, pages: list[Page]) -> list[Page]:
|
||||||
|
if (
|
||||||
|
self.policy.canonical_source is CanonicalSource.DESIGNATED_CANONICAL
|
||||||
|
and self.policy.designated_shard is not None
|
||||||
|
):
|
||||||
|
pages = sorted(
|
||||||
|
pages, key=lambda p: p.identity.shard != self.policy.designated_shard
|
||||||
|
)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def _with_divergence(page: Page, group: list[Page]) -> Page:
|
||||||
|
peers = tuple(str(p.identity) for p in group if p.identity != page.identity)
|
||||||
|
new_env = dataclasses.replace(page.envelope, divergence=peers)
|
||||||
|
return dataclasses.replace(page, envelope=new_env)
|
||||||
67
tests/test_conformance.py
Normal file
67
tests/test_conformance.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for the adapter conformance suite (SHARD-WP-0007 T4)."""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shard_wiki.adapters import (
|
||||||
|
ConformanceError,
|
||||||
|
FolderAdapter,
|
||||||
|
ShardAdapter,
|
||||||
|
assert_conformant,
|
||||||
|
run_conformance,
|
||||||
|
)
|
||||||
|
from shard_wiki.model import CapabilityProfile, Identity, Page
|
||||||
|
from shard_wiki.provenance import ProvenanceEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
def test_folder_adapter_is_conformant(tmp_path):
|
||||||
|
(tmp_path / "Home.md").write_text("# Home", encoding="utf-8")
|
||||||
|
report = assert_conformant(FolderAdapter("shardA", tmp_path))
|
||||||
|
assert report.ok
|
||||||
|
assert report.diff() == "conformant"
|
||||||
|
|
||||||
|
|
||||||
|
class _LyingReadAdapter(ShardAdapter):
|
||||||
|
"""Claims READ but read() is broken — must fail conformance."""
|
||||||
|
|
||||||
|
def __init__(self, profile: CapabilityProfile) -> None:
|
||||||
|
self._profile = profile
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shard_id(self) -> str:
|
||||||
|
return "liar"
|
||||||
|
|
||||||
|
def profile(self) -> CapabilityProfile:
|
||||||
|
return self._profile
|
||||||
|
|
||||||
|
def keys(self) -> Iterable[str]:
|
||||||
|
return ["Home"]
|
||||||
|
|
||||||
|
def read(self, key: str) -> Page:
|
||||||
|
# Returns a page attributed to the WRONG shard — a lie about its own content.
|
||||||
|
return Page(Identity("someone-else", key), "x", ProvenanceEnvelope(source_shard="x"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_lying_read_adapter_fails_with_precise_diff(tmp_path):
|
||||||
|
good = FolderAdapter("shardA", tmp_path).profile() # a valid read-only profile
|
||||||
|
liar = _LyingReadAdapter(good)
|
||||||
|
report = run_conformance(liar)
|
||||||
|
assert not report.ok
|
||||||
|
assert "read-round-trips" in report.diff()
|
||||||
|
with pytest.raises(ConformanceError, match="read-round-trips"):
|
||||||
|
assert_conformant(liar)
|
||||||
|
|
||||||
|
|
||||||
|
class _DishonestWriteAdapter(FolderAdapter):
|
||||||
|
"""Declares no WRITE (inherits read-only folder profile) but write() silently succeeds."""
|
||||||
|
|
||||||
|
def write(self, key: str, body: str) -> Page: # noqa: ARG002
|
||||||
|
return self.read(next(iter(self.keys())))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dishonest_absence_fails(tmp_path):
|
||||||
|
(tmp_path / "Home.md").write_text("x", encoding="utf-8")
|
||||||
|
report = run_conformance(_DishonestWriteAdapter("shardA", tmp_path))
|
||||||
|
assert not report.ok
|
||||||
|
assert "honest-absence:write" in report.diff()
|
||||||
51
tests/test_decision_log.py
Normal file
51
tests/test_decision_log.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for the event-sourced DecisionLog (SHARD-WP-0007 T5)."""
|
||||||
|
|
||||||
|
from shard_wiki.coordination import DecisionLog, EventType
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_is_totally_ordered_per_space():
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("spaceA", EventType.ALIAS_SET, {"alias": "Home", "target": "shardA:Index"})
|
||||||
|
log.append("spaceA", EventType.ALIAS_SET, {"alias": "Start", "target": "shardA:Index"})
|
||||||
|
log.append("spaceB", EventType.ALIAS_SET, {"alias": "X", "target": "shardB:Y"})
|
||||||
|
seqs = [e.seq for e in log.events("spaceA")]
|
||||||
|
assert seqs == [0, 1] # per-space monotonic
|
||||||
|
assert [e.seq for e in log.events("spaceB")] == [0] # independent ordering
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_your_writes():
|
||||||
|
log = DecisionLog()
|
||||||
|
ev = log.append("s", EventType.ALIAS_SET, {"alias": "a", "target": "shardA:b"})
|
||||||
|
assert log.events("s")[-1] is ev
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_reproduces_current_state():
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("s", EventType.ALIAS_SET, {"alias": "Home", "target": "shardA:Index"})
|
||||||
|
log.append("s", EventType.ALIAS_SET, {"alias": "Home", "target": "shardB:Main"}) # LWW
|
||||||
|
state = log.fold("s")
|
||||||
|
assert state.resolve_alias("Home") == "shardB:Main"
|
||||||
|
assert state.resolve_alias("missing") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_is_pure_function_of_log():
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("s", EventType.BINDING_MADE, {"members": ["shardA:Home", "shardB:Home"]})
|
||||||
|
first = log.fold("s")
|
||||||
|
second = log.fold("s")
|
||||||
|
assert first.equivalence_groups == second.equivalence_groups # derived = f(log)
|
||||||
|
|
||||||
|
|
||||||
|
def test_equivalence_groups_merge_transitively():
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("s", EventType.BINDING_MADE, {"members": ["a", "b"]})
|
||||||
|
log.append("s", EventType.BINDING_MADE, {"members": ["b", "c"]})
|
||||||
|
state = log.fold("s")
|
||||||
|
assert state.equivalent_to("a") == frozenset({"a", "b", "c"})
|
||||||
|
assert state.equivalent_to("lonely") == frozenset({"lonely"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_fork_creates_equivalence():
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("s", EventType.PAGE_FORKED, {"source": "shardA:Doc", "fork": "shardB:Doc"})
|
||||||
|
assert log.fold("s").equivalent_to("shardA:Doc") == frozenset({"shardA:Doc", "shardB:Doc"})
|
||||||
44
tests/test_folder_adapter.py
Normal file
44
tests/test_folder_adapter.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Tests for the FolderAdapter (SHARD-WP-0007 T3)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shard_wiki.adapters import FolderAdapter
|
||||||
|
from shard_wiki.model import Identity, NotSupported, Verb
|
||||||
|
|
||||||
|
|
||||||
|
def _make_shard(tmp_path, files: dict[str, str]) -> FolderAdapter:
|
||||||
|
for rel, text in files.items():
|
||||||
|
p = tmp_path / rel
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(text, encoding="utf-8")
|
||||||
|
return FolderAdapter("shardA", tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_keys_and_read(tmp_path):
|
||||||
|
shard = _make_shard(tmp_path, {"Home.md": "# Home", "sub/Topic.md": "topic body"})
|
||||||
|
assert set(shard.keys()) == {"Home", "sub/Topic"}
|
||||||
|
page = shard.read("sub/Topic")
|
||||||
|
assert page.identity == Identity("shardA", "sub/Topic")
|
||||||
|
assert page.body == "topic body"
|
||||||
|
assert page.envelope.source_shard == "shardA"
|
||||||
|
assert page.envelope.source_rev is not None # mtime stamp
|
||||||
|
assert page.placements[0].path == "sub/Topic.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_missing_raises_keyerror(tmp_path):
|
||||||
|
shard = _make_shard(tmp_path, {"Home.md": "x"})
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
shard.read("Nope")
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_is_valid_and_read_only(tmp_path):
|
||||||
|
shard = _make_shard(tmp_path, {"Home.md": "x"})
|
||||||
|
prof = shard.profile() # .validate() already called inside
|
||||||
|
assert prof.supports(Verb.READ)
|
||||||
|
assert not prof.supports(Verb.WRITE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_write_is_honest(tmp_path):
|
||||||
|
shard = _make_shard(tmp_path, {"Home.md": "x"})
|
||||||
|
with pytest.raises(NotSupported):
|
||||||
|
shard.write("Home", "new")
|
||||||
80
tests/test_integration.py
Normal file
80
tests/test_integration.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""End-to-end slice test (SHARD-WP-0007 T7): attach folder shards → resolve → read union."""
|
||||||
|
|
||||||
|
import shard_wiki
|
||||||
|
from shard_wiki import InformationSpace
|
||||||
|
from shard_wiki.adapters import ConformanceError, FolderAdapter, ShardAdapter
|
||||||
|
from shard_wiki.model import CapabilityProfile, Identity, Page
|
||||||
|
from shard_wiki.provenance import ProvenanceEnvelope, Staleness
|
||||||
|
from shard_wiki.union import ResolutionKind
|
||||||
|
|
||||||
|
|
||||||
|
def _folder(tmp_path, name, files):
|
||||||
|
root = tmp_path / name
|
||||||
|
for rel, text in files.items():
|
||||||
|
p = root / rel
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(text, encoding="utf-8")
|
||||||
|
return FolderAdapter(name, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_api_surface():
|
||||||
|
assert isinstance(shard_wiki.__version__, str) and shard_wiki.__version__
|
||||||
|
assert shard_wiki.InformationSpace is InformationSpace
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_resolve_read_union_with_provenance_and_chorus(tmp_path):
|
||||||
|
space = InformationSpace("team")
|
||||||
|
space.attach(_folder(tmp_path, "wikiA", {"Home.md": "A home", "OnlyA.md": "a"}))
|
||||||
|
space.attach(_folder(tmp_path, "wikiB", {"Home.md": "B home", "OnlyB.md": "b"}))
|
||||||
|
|
||||||
|
# unique page → single, with provenance attributing the source shard
|
||||||
|
only_a = space.read("OnlyA")
|
||||||
|
assert only_a.body == "a"
|
||||||
|
assert only_a.envelope.source_shard == "wikiA"
|
||||||
|
assert only_a.envelope.staleness is Staleness.FRESH
|
||||||
|
assert only_a.envelope.observed_at is not None
|
||||||
|
|
||||||
|
# same name in both shards → chorus, divergence recorded both ways (union without erasure)
|
||||||
|
res = space.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.CHORUS
|
||||||
|
assert {p.envelope.source_shard for p in res.pages} == {"wikiA", "wikiB"}
|
||||||
|
for p in res.pages:
|
||||||
|
assert len(p.envelope.divergence) == 1
|
||||||
|
|
||||||
|
# missing → red-link
|
||||||
|
assert space.resolve("Ghost").is_red_link
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_redirects_across_the_union(tmp_path):
|
||||||
|
space = InformationSpace("team")
|
||||||
|
space.attach(_folder(tmp_path, "wikiA", {"Index.md": "the index"}))
|
||||||
|
space.alias("Start", "wikiA:Index")
|
||||||
|
assert space.read("Start").body == "the index"
|
||||||
|
|
||||||
|
|
||||||
|
class _LyingShard(ShardAdapter):
|
||||||
|
def __init__(self, profile: CapabilityProfile) -> None:
|
||||||
|
self._p = profile
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shard_id(self) -> str:
|
||||||
|
return "liar"
|
||||||
|
|
||||||
|
def profile(self) -> CapabilityProfile:
|
||||||
|
return self._p
|
||||||
|
|
||||||
|
def keys(self):
|
||||||
|
return ["X"]
|
||||||
|
|
||||||
|
def read(self, key: str) -> Page:
|
||||||
|
return Page(Identity("not-liar", key), "x", ProvenanceEnvelope(source_shard="x"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_rejects_nonconformant_shard(tmp_path):
|
||||||
|
good_profile = _folder(tmp_path, "wikiA", {"Home.md": "x"}).profile()
|
||||||
|
space = InformationSpace("team")
|
||||||
|
try:
|
||||||
|
space.attach(_LyingShard(good_profile))
|
||||||
|
raise AssertionError("expected ConformanceError")
|
||||||
|
except ConformanceError:
|
||||||
|
pass
|
||||||
106
tests/test_model.py
Normal file
106
tests/test_model.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Tests for the page model + capability profile (SHARD-WP-0007 T2)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shard_wiki.model import (
|
||||||
|
AccessGrant,
|
||||||
|
Addressing,
|
||||||
|
AttachmentMode,
|
||||||
|
CapabilityProfile,
|
||||||
|
ContentOpacity,
|
||||||
|
History,
|
||||||
|
Identity,
|
||||||
|
MergeModel,
|
||||||
|
NativeQuery,
|
||||||
|
OperationalEnvelope,
|
||||||
|
Page,
|
||||||
|
PageShape,
|
||||||
|
ProfileError,
|
||||||
|
Substrate,
|
||||||
|
Translation,
|
||||||
|
Verb,
|
||||||
|
WriteGranularity,
|
||||||
|
)
|
||||||
|
from shard_wiki.provenance import Liveness, ProvenanceEnvelope
|
||||||
|
|
||||||
|
|
||||||
|
def _read_only_folder_profile(**over) -> CapabilityProfile:
|
||||||
|
base = dict(
|
||||||
|
substrate=Substrate.FILES,
|
||||||
|
attachment_mode=AttachmentMode.FILE_STORE,
|
||||||
|
write_granularity=WriteGranularity.NONE,
|
||||||
|
content_opacity=ContentOpacity.TRANSPARENT,
|
||||||
|
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||||
|
access_grant=AccessGrant.OPEN,
|
||||||
|
liveness=Liveness.STATIC,
|
||||||
|
history=History.NONE,
|
||||||
|
merge_model=MergeModel.NONE,
|
||||||
|
addressing=Addressing.PATH,
|
||||||
|
native_query=NativeQuery.NONE,
|
||||||
|
translation=Translation.NATIVE,
|
||||||
|
supported_verbs=frozenset({Verb.READ}),
|
||||||
|
)
|
||||||
|
base.update(over)
|
||||||
|
return CapabilityProfile(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_is_stable_across_content_and_value_equal():
|
||||||
|
a = Identity("shardA", "Home")
|
||||||
|
b = Identity("shardA", "Home")
|
||||||
|
assert a == b and hash(a) == hash(b)
|
||||||
|
assert str(a) == "shardA:Home"
|
||||||
|
# Identity does not depend on content: a Page edit reuses the same identity.
|
||||||
|
env = ProvenanceEnvelope(source_shard="shardA")
|
||||||
|
p1 = Page(a, "first body", env)
|
||||||
|
p2 = Page(a, "edited body", env)
|
||||||
|
assert p1.identity == p2.identity # stable handle, not a fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_folder_profile_validates():
|
||||||
|
assert _read_only_folder_profile().validate().supports(Verb.READ)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_read_is_rejected():
|
||||||
|
with pytest.raises(ProfileError, match="READ"):
|
||||||
|
_read_only_folder_profile(supported_verbs=frozenset()).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_is_store_requires_git_substrate_and_history():
|
||||||
|
with pytest.raises(ProfileError, match="git-is-store"):
|
||||||
|
_read_only_folder_profile(attachment_mode=AttachmentMode.GIT_IS_STORE).validate()
|
||||||
|
# consistent git-is-store profile validates
|
||||||
|
_read_only_folder_profile(
|
||||||
|
attachment_mode=AttachmentMode.GIT_IS_STORE,
|
||||||
|
substrate=Substrate.GIT,
|
||||||
|
history=History.GIT_NATIVE,
|
||||||
|
).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypted_forbids_native_query():
|
||||||
|
with pytest.raises(ProfileError, match="encrypted"):
|
||||||
|
_read_only_folder_profile(
|
||||||
|
content_opacity=ContentOpacity.ENCRYPTED, native_query=NativeQuery.DB_QUERY
|
||||||
|
).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_verb_requires_write_granularity():
|
||||||
|
with pytest.raises(ProfileError, match="read-only consistency"):
|
||||||
|
_read_only_folder_profile(supported_verbs=frozenset({Verb.READ, Verb.WRITE})).validate()
|
||||||
|
# writable profile is consistent
|
||||||
|
_read_only_folder_profile(
|
||||||
|
write_granularity=WriteGranularity.PER_PAGE,
|
||||||
|
supported_verbs=frozenset({Verb.READ, Verb.WRITE}),
|
||||||
|
).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_crdt_merge_requires_crdt_log_and_realtime():
|
||||||
|
with pytest.raises(ProfileError, match="native-crdt"):
|
||||||
|
_read_only_folder_profile(
|
||||||
|
merge_model=MergeModel.NATIVE_CRDT,
|
||||||
|
supported_verbs=frozenset({Verb.READ, Verb.MERGE}),
|
||||||
|
).validate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_defaults_to_prose():
|
||||||
|
p = Page(Identity("s", "k"), "x", ProvenanceEnvelope(source_shard="s"))
|
||||||
|
assert p.shape is PageShape.PROSE
|
||||||
73
tests/test_provenance.py
Normal file
73
tests/test_provenance.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Tests for the provenance leaf rail (SHARD-WP-0007 T1)."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from shard_wiki.provenance import (
|
||||||
|
Liveness,
|
||||||
|
OverlayState,
|
||||||
|
ProvenanceEnvelope,
|
||||||
|
SpanProvenanceDelta,
|
||||||
|
Staleness,
|
||||||
|
effective,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _base() -> ProvenanceEnvelope:
|
||||||
|
return ProvenanceEnvelope(
|
||||||
|
source_shard="shardA",
|
||||||
|
liveness=Liveness.STATIC,
|
||||||
|
staleness=Staleness.FRESH,
|
||||||
|
source_rev="r1",
|
||||||
|
observed_at=datetime(2026, 6, 15, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_delta_returns_base_identity():
|
||||||
|
base = _base()
|
||||||
|
assert effective(base) is base
|
||||||
|
assert effective(base, SpanProvenanceDelta()) is base
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_delta_is_detected():
|
||||||
|
assert SpanProvenanceDelta().is_empty()
|
||||||
|
assert not SpanProvenanceDelta(source_shard="other").is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_field_override():
|
||||||
|
base = _base()
|
||||||
|
eff = effective(base, SpanProvenanceDelta(source_shard="shardB"))
|
||||||
|
assert eff.source_shard == "shardB"
|
||||||
|
# Untouched fields inherit from the page envelope.
|
||||||
|
assert eff.source_rev == "r1"
|
||||||
|
assert eff.liveness is Liveness.STATIC
|
||||||
|
# Base is unchanged (frozen, pure composition).
|
||||||
|
assert base.source_shard == "shardA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_delta_overrides_several_fields():
|
||||||
|
base = _base()
|
||||||
|
eff = effective(
|
||||||
|
base,
|
||||||
|
SpanProvenanceDelta(
|
||||||
|
staleness=Staleness.STALE,
|
||||||
|
overlay_state=OverlayState.DRAFT,
|
||||||
|
divergence=("shardB:Home",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert eff.staleness is Staleness.STALE
|
||||||
|
assert eff.overlay_state is OverlayState.DRAFT
|
||||||
|
assert eff.divergence == ("shardB:Home",)
|
||||||
|
assert eff.source_shard == "shardA" # inherited
|
||||||
|
|
||||||
|
|
||||||
|
def test_divergence_empty_tuple_overrides_but_none_inherits():
|
||||||
|
base = ProvenanceEnvelope(source_shard="s", divergence=("x",))
|
||||||
|
# None → inherit the page's divergence.
|
||||||
|
assert effective(base, SpanProvenanceDelta(divergence=None)).divergence == ("x",)
|
||||||
|
# Explicit empty tuple → override (clear it).
|
||||||
|
assert effective(base, SpanProvenanceDelta(divergence=())).divergence == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_is_hashable_and_value_equal():
|
||||||
|
assert _base() == _base()
|
||||||
|
assert len({_base(), _base()}) == 1
|
||||||
69
tests/test_union.py
Normal file
69
tests/test_union.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Tests for union resolution (SHARD-WP-0007 T6)."""
|
||||||
|
|
||||||
|
from shard_wiki.adapters import FolderAdapter
|
||||||
|
from shard_wiki.coordination import DecisionLog, EventType
|
||||||
|
from shard_wiki.policy import CanonicalSource, Policy
|
||||||
|
from shard_wiki.union import ResolutionKind, UnionGraph
|
||||||
|
|
||||||
|
|
||||||
|
def _shard(tmp_path, name, files):
|
||||||
|
root = tmp_path / name
|
||||||
|
for rel, text in files.items():
|
||||||
|
p = root / rel
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(text, encoding="utf-8")
|
||||||
|
return FolderAdapter(name, root)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_resolution(tmp_path):
|
||||||
|
u = UnionGraph("space")
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "A home"}))
|
||||||
|
res = u.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.SINGLE
|
||||||
|
assert res.single().body == "A home"
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_link_when_absent(tmp_path):
|
||||||
|
u = UnionGraph("space")
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "x"}))
|
||||||
|
assert u.resolve("Nope").is_red_link
|
||||||
|
|
||||||
|
|
||||||
|
def test_chorus_on_ambiguity_records_divergence(tmp_path):
|
||||||
|
u = UnionGraph("space")
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "A home"}))
|
||||||
|
u.attach(_shard(tmp_path, "shardB", {"Home.md": "B home"}))
|
||||||
|
res = u.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.CHORUS
|
||||||
|
assert {p.body for p in res.pages} == {"A home", "B home"}
|
||||||
|
# Each page names its divergent peer — union without erasure.
|
||||||
|
a = next(p for p in res.pages if p.identity.shard == "shardA")
|
||||||
|
assert a.envelope.divergence == ("shardB:Home",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_designated_canonical_orders_first(tmp_path):
|
||||||
|
policy = Policy(canonical_source=CanonicalSource.DESIGNATED_CANONICAL, designated_shard="shardB")
|
||||||
|
u = UnionGraph("space", policy=policy)
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Home.md": "A"}))
|
||||||
|
u.attach(_shard(tmp_path, "shardB", {"Home.md": "B"}))
|
||||||
|
res = u.resolve("Home")
|
||||||
|
assert res.kind is ResolutionKind.CHORUS
|
||||||
|
assert res.single().identity.shard == "shardB" # designated wins the canonical pick
|
||||||
|
|
||||||
|
|
||||||
|
def test_alias_from_log_redirects(tmp_path):
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("space", EventType.ALIAS_SET, {"alias": "Start", "target": "shardA:Index"})
|
||||||
|
u = UnionGraph("space", log=log)
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Index.md": "the index"}))
|
||||||
|
res = u.resolve("Start")
|
||||||
|
assert res.kind is ResolutionKind.SINGLE
|
||||||
|
assert res.single().body == "the index"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dangling_alias_falls_through_to_red_link(tmp_path):
|
||||||
|
log = DecisionLog()
|
||||||
|
log.append("space", EventType.ALIAS_SET, {"alias": "Start", "target": "shardA:Missing"})
|
||||||
|
u = UnionGraph("space", log=log)
|
||||||
|
u.attach(_shard(tmp_path, "shardA", {"Index.md": "x"}))
|
||||||
|
assert u.resolve("Start").is_red_link
|
||||||
@@ -4,13 +4,14 @@ type: workplan
|
|||||||
title: "foundation implementation — model, contract, decision log, union read"
|
title: "foundation implementation — model, contract, decision log, union read"
|
||||||
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"
|
||||||
updated: "2026-06-15"
|
updated: "2026-06-15"
|
||||||
depends_on:
|
depends_on:
|
||||||
- SHARD-WP-0002
|
- SHARD-WP-0002
|
||||||
|
state_hub_workstream_id: "d551fba1-9c48-4841-a9a5-a9f190f73a60"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SHARD-WP-0007 — Foundation implementation
|
# SHARD-WP-0007 — Foundation implementation
|
||||||
@@ -41,8 +42,9 @@ projection, authz beyond the null provider, a network API. Those are later workp
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T1
|
id: SHARD-WP-0007-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "be8f9efe-cfba-46d0-be91-f9b6e87bc0d2"
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/shard_wiki/provenance/`: the `ProvenanceEnvelope` value type (source_shard, source_rev?,
|
`src/shard_wiki/provenance/`: the `ProvenanceEnvelope` value type (source_shard, source_rev?,
|
||||||
@@ -54,8 +56,9 @@ delta). Frozen dataclasses, no tree deps. Tests: ⊕ identity (no delta), overri
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T2
|
id: SHARD-WP-0007-T2
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "780ad01f-c3e1-4b49-9ae9-60e0324178a7"
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/shard_wiki/model/`: `Identity` (shard-scoped stable handle, value-equal, survives edits),
|
`src/shard_wiki/model/`: `Identity` (shard-scoped stable handle, value-equal, survives edits),
|
||||||
@@ -68,8 +71,9 @@ identity stability vs content change; profile validation accepts/rejects.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T3
|
id: SHARD-WP-0007-T3
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "f6e35ddb-ab1e-406a-82f8-563244455f6b"
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/shard_wiki/adapters/`: `AdapterContract` (a `Protocol`/ABC with the operation verbs;
|
`src/shard_wiki/adapters/`: `AdapterContract` (a `Protocol`/ABC with the operation verbs;
|
||||||
@@ -82,8 +86,9 @@ git-or-none history). Imports `model/`, `provenance/`. Tests: read a temp folder
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T4
|
id: SHARD-WP-0007-T4
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "12d7fb8d-3842-4142-a462-9d1e6efe58bd"
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/shard_wiki/adapters/conformance.py`: a battery that, given a binding, **verifies declared
|
`src/shard_wiki/adapters/conformance.py`: a battery that, given a binding, **verifies declared
|
||||||
@@ -96,8 +101,9 @@ lying stub fails with a precise diff.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T5
|
id: SHARD-WP-0007-T5
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "c87b1896-59a5-4cde-a292-1086caebd085"
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/shard_wiki/coordination/`: `DecisionLog` — append-only, **totally ordered per space**
|
`src/shard_wiki/coordination/`: `DecisionLog` — append-only, **totally ordered per space**
|
||||||
@@ -110,8 +116,9 @@ append→ordered read; fold reproduces current state; read-your-writes.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T6
|
id: SHARD-WP-0007-T6
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "fed38b60-dc0b-40cf-93e9-ab0260aa3ff9"
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/shard_wiki/policy/` (leaf: presets + `resolve()` for canonical-source = chorus default);
|
`src/shard_wiki/policy/` (leaf: presets + `resolve()` for canonical-source = chorus default);
|
||||||
@@ -124,8 +131,9 @@ the same name → chorus; alias from the log redirects resolution.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0007-T7
|
id: SHARD-WP-0007-T7
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "86c0b255-e373-460d-859d-a39bf6ea4ffb"
|
||||||
```
|
```
|
||||||
|
|
||||||
A thin `shard_wiki` entry (attach shard, resolve, read) tying the slice together; an
|
A thin `shard_wiki` entry (attach shard, resolve, read) tying the slice together; an
|
||||||
|
|||||||
Reference in New Issue
Block a user