MuseDomainPlugin Protocol — Language-Agnostic Specification
Status: Canonical · Version: v0.1.2 Audience: Anyone implementing a Muse domain plugin in any language.
0. Purpose
This document specifies the six-method contract a domain plugin must satisfy to integrate with the Muse VCS engine, plus the two optional protocol extensions for richer merge semantics. It is intentionally language-agnostic.
Muse provides the DAG, object store, branching, lineage, merge state machine, log, and CLI. A plugin provides domain knowledge. This document defines the boundary between them.
1. Design Principles
- Plugins are pure transformations. A plugin method takes state in, returns state out. Side effects (writing to disk, calling APIs) belong to the CLI layer, not the plugin.
- All state is JSON-serializable. Snapshots must be serializable to a content-addressable string. No opaque blobs inside snapshot values.
- Content-addressed identity. The same state must always produce the same snapshot. Snapshots are compared by their SHA-256 digest — not by object identity.
- Idempotent writes. Writing an object or snapshot that already exists is a no-op. The store never overwrites existing content.
- Conflicts are data, not exceptions. A conflicted merge returns a
MergeResultwith a non-emptyconflictslist. It does not raise. - Drift is always relative.
drift()compares committed state against live state. It never modifies either.
2. Type Definitions
All types use Python as the reference notation. Implementations in other languages should map to equivalent constructs.
# A workspace-relative path mapped to its SHA-256 content digest.
# Must contain "files": dict[str, str] and "domain": str.
StateSnapshot = TypedDict("StateSnapshot", files=dict[str, str], domain=str)
# The "live" input to snapshot() and drift().
# Either a filesystem path to the working directory,
# or an existing StateSnapshot (used for in-memory operations).
LiveState = Path | StateSnapshot
# Output of diff(): a typed list of domain operations.
# StructuredDelta carries insert / delete / move / replace / patch ops,
# each with a content-addressed before/after reference.
StateDelta = StructuredDelta # see type-contracts.md for the full shape
# Output of merge(): the reconciled snapshot + conflict + strategy metadata.
MergeResult = dataclass(
merged: StateSnapshot,
conflicts: list[str],
applied_strategies: dict[str, str],
dimension_reports: dict[str, dict[str, str]],
)
# Output of drift(): summary of how live state diverges from committed state.
DriftReport = dataclass(has_drift: bool, summary: str, delta: StateDelta)
# Output of schema(): structural declaration of the domain's data shape.
DomainSchema = TypedDict with keys: domain, schema_version, description,
merge_mode, elements, dimensions # see type-contracts.md
3. The Six Required Methods
3.1 snapshot(live_state: LiveState) → StateSnapshot
Capture the current live state as a serializable, content-addressable snapshot.
Contract:
- The return value MUST be JSON-serializable.
- The return value MUST contain a
"files"key mapping workspace-relative path strings to their SHA-256 hex digests. - The return value MUST contain a
"domain"key matching the plugin's domain name. - Given identical input, the output MUST be identical (deterministic).
- If
live_stateis already aStateSnapshotdict, return it unchanged.
Called by: muse commit, muse shelf save
3.2 diff(base: StateSnapshot, target: StateSnapshot, *, repo_root: Path | None = None) → StateDelta
Compute the typed delta between two snapshots.
Contract:
- Return MUST be a
StructuredDeltacontaining a typedopslist. - Each operation MUST have an
opkind:"insert","delete","move","replace", or"patch". - Each operation MUST have an
"address"field identifying the element within the domain's namespace. - Return MUST contain
"domain"matching the plugin's domain name. diff(s, s)MUST return an emptyopslist for identical snapshots.
Called by: muse diff, muse checkout, muse read
3.3 merge(base, left, right: StateSnapshot, *, repo_root: Path | None = None) → MergeResult
Three-way merge two divergent state lines against a common ancestor.
Contract:
baseis the common ancestor (merge base).leftis the current branch's snapshot (ours).rightis the incoming branch's snapshot (theirs).repo_root, when provided, is the filesystem root of the repository. Implementations SHOULD use it to load.museattributesand apply file-level or dimension-level merge strategies before falling back to conflict reporting.result.mergedMUST be a validStateSnapshot.result.conflictsMUST be a list of workspace-relative path strings.- An empty list means the merge was clean.
- Paths in
result.conflictsMUST also appear inresult.merged(placeholder state).
result.applied_strategiesmaps paths where a.museattributesrule overrode the default conflict behaviour to the strategy string that was used. Plugins SHOULD populate this for observability; it MAY be empty.result.dimension_reportsmaps paths that received dimension-level merge to a{dimension: winner}dict for each resolved dimension. Plugins that do not support dimension merge MAY always return{}.- Consensus deletion (both sides deleted the same path) is NOT a conflict.
- This method MUST NOT raise on conflict — it returns the conflict list instead.
Called by: muse merge, muse cherry-pick
3.4 drift(committed: StateSnapshot, live: LiveState) → DriftReport
Detect how far the live state has diverged from the last committed snapshot.
Contract:
result.has_driftisTrueif and only ifdeltais non-empty.result.summaryis a human-readable string (e.g."2 added, 1 modified"or"working tree clean").result.deltais a validStateDelta.- This method MUST NOT modify any state.
Called by: muse status
3.5 apply(delta: StateDelta, live_state: LiveState) → LiveState
Apply a delta to produce a new live state. Serves as the domain-level post-checkout hook.
Contract:
- When
live_stateis a filesystemPath: the caller has already applied the delta physically (removed deleted files, restored added/modified from the object store). The plugin SHOULD rescan the directory and return the authoritative new state as aStateSnapshot. - When
live_stateis aStateSnapshotdict: apply removals to the in-memory dict. Added/modified paths SHOULD be noted as limitations — the delta does not carry content hashes, so the caller must supply them through another path. - The return value MUST be a valid
LiveState.
Called by: muse checkout
3.6 schema() → DomainSchema
Declare the structural shape of the domain's data.
Contract:
- Return MUST be a
DomainSchemaTypedDict. schema["domain"]MUST match the plugin's domain name.schema["merge_mode"]MUST be one of"three_way"or"crdt".schema["elements"]MUST be a non-empty list ofElementSchemaentries, each with a"name"and"kind"field.schema["dimensions"]MUST be a list ofDimensionSpecentries, each with"name","description", and"diff_algorithm"fields.- This method MUST be idempotent (calling it multiple times returns structurally identical values).
Called by: muse domains, diff algorithm selection, merge engine conflict reporting.
4. Optional Protocol Extensions
4.1 StructuredMergePlugin — Operational Transformation Merge
Plugins may optionally implement StructuredMergePlugin by adding a merge_ops() method.
class StructuredMergePlugin(MuseDomainPlugin, Protocol):
def merge_ops(
self,
base: StateSnapshot,
ours_snap: StateSnapshot,
theirs_snap: StateSnapshot,
ours_ops: list[DomainOp],
theirs_ops: list[DomainOp],
*,
repo_root: pathlib.Path | None = None,
) -> MergeResult: ...
When both branches produce a StructuredDelta from diff(), the merge engine
detects isinstance(plugin, StructuredMergePlugin) and calls merge_ops() for
operation-level conflict detection. Non-commuting operations become the minimal,
real conflict set. Non-supporting plugins fall back to the file-level merge() path.
Contract for merge_ops():
ours_opsandtheirs_opsare the typed operation lists from each branch'sStructuredDelta.- The engine applies OT commutativity rules to determine which ops are auto-mergeable.
result.conflictscontains only addresses where the operations genuinely conflict (non-commuting writes to the same address).
4.2 CRDTPlugin — Convergent Multi-Agent Merge
Plugins may optionally implement CRDTPlugin by adding four methods.
class CRDTPlugin(MuseDomainPlugin, Protocol):
def join(
self,
a: CRDTSnapshotManifest,
b: CRDTSnapshotManifest,
) -> CRDTSnapshotManifest: ...
def crdt_schema(self) -> list[CRDTDimensionSpec]: ...
def to_crdt_state(self, snapshot: StateSnapshot) -> CRDTSnapshotManifest: ...
def from_crdt_state(self, crdt: CRDTSnapshotManifest) -> StateSnapshot: ...
join always succeeds — no conflict state ever exists. Given any two
CRDTSnapshotManifest values, join produces a deterministic merged result
regardless of message delivery order. The engine detects CRDTPlugin via
isinstance at merge time. DomainSchema.merge_mode == "crdt" signals that
the CRDT path should be taken.
Lattice laws join must satisfy:
- Commutativity:
join(a, b) == join(b, a) - Associativity:
join(join(a, b), c) == join(a, join(b, c)) - Idempotency:
join(a, a) == a
Violation of any lattice law breaks convergence.
5. Snapshot Format (Normative)
The minimum required shape for a StateSnapshot:
{
"files": {
"path/to/file-a": "sha256-hex-64-chars",
"path/to/file-b": "sha256-hex-64-chars"
},
"domain": "my_domain_name"
}
Plugins MAY add additional top-level keys for domain-specific metadata:
{
"files": { ... },
"domain": "midi",
"tempo_bpm": 120,
"key": "Am"
}
Additional keys MUST be JSON-serializable. The core engine ignores them; they
are available to domain-specific CLI commands via plugin.snapshot().
6. Naming Conventions
| Scope | Convention |
|---|---|
| Wire format (JSON) | camelCase |
| Python internals | snake_case |
Plugin domain name in repo.json |
snake_case |
| Workspace-relative paths in snapshots | POSIX forward-slash separators |
7. Implementing a Plugin
Minimum viable implementation in Python (required methods only):
import pathlib
from muse.domain import (
DriftReport, LiveState, MergeResult,
MuseDomainPlugin, SnapshotManifest, StructuredDelta, StateSnapshot,
)
from muse.core.schema import DomainSchema
class MyDomainPlugin:
def snapshot(self, live_state: LiveState) -> StateSnapshot:
if isinstance(live_state, pathlib.Path):
files = {
f.relative_to(live_state).as_posix(): _hash(f)
for f in sorted(live_state.rglob("*"))
if f.is_file()
}
return SnapshotManifest(files=files, domain="my_domain")
return live_state # already a snapshot dict
def diff(
self,
base: StateSnapshot,
target: StateSnapshot,
*,
repo_root: pathlib.Path | None = None,
) -> StructuredDelta:
# Compute typed operations between base and target.
# Return StructuredDelta(domain="my_domain", ops=[...], summary="...")
...
def merge(
self,
base: StateSnapshot,
left: StateSnapshot,
right: StateSnapshot,
*,
repo_root: pathlib.Path | None = None,
) -> MergeResult:
# Domain-specific reconciliation.
# Load .museattributes if repo_root is provided and apply strategies.
...
def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
live_snap = self.snapshot(live)
delta = self.diff(committed, live_snap)
has_drift = bool(delta["ops"])
return DriftReport(has_drift=has_drift, summary="...", delta=delta)
def apply(self, delta: StructuredDelta, live_state: LiveState) -> LiveState:
if isinstance(live_state, pathlib.Path):
return self.snapshot(live_state)
# Apply deletions to in-memory snapshot dict.
...
def schema(self) -> DomainSchema:
return DomainSchema(
domain="my_domain",
schema_version=1,
description="...",
merge_mode="three_way",
elements=[...],
dimensions=[...],
)
See muse/plugins/scaffold/plugin.py for the copy-paste template implementing all
methods including the StructuredMergePlugin and CRDTPlugin extensions.
See muse/plugins/midi/plugin.py for the complete reference implementation.
8. Invariants the Core Engine Relies On
The core engine assumes:
snapshot(snapshot_dict)returns the dict unchanged.diff(s, s)returns an emptyopslist for identical snapshots.merge(base, s, s)returnsswith an emptyconflictslist.drift(s, path_to_workdir_matching_s)returnshas_drift=False.- Object IDs in
StateSnapshot["files"]are valid SHA-256 hex strings (64 chars). schema()always returns structurally identical values (idempotent).
Violating these invariants will cause incorrect behavior in checkout, status,
and merge state detection.