feat(coordination): event-sourced DecisionLog keystone (WP-0007 T5)

Append-only, totally-ordered-per-space decision log (in-process append authority;
git+lease later) with event types overlay/binding/alias/merge/fork, and a derived
fold to CoordinationState (aliases LWW, transitively-merged equivalence groups,
open overlays). derived = f(log); read-your-writes. 6 tests green. (blueprint §8.1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:45:11 +02:00
parent 6d48a1d3e6
commit 24108b65aa
4 changed files with 186 additions and 1 deletions

View 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"]

View 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)

View 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"})

View File

@@ -101,7 +101,7 @@ lying stub fails with a precise diff.
```task
id: SHARD-WP-0007-T5
status: todo
status: done
priority: high
state_hub_task_id: "c87b1896-59a5-4cde-a292-1086caebd085"
```