Domain Protocol
The MuseDomainPlugin protocol is the single seam between
domain-specific knowledge and the Muse core engine. Implement six methods and
any state space — MIDI, source code, genomics, 3D spatial, financial models,
identity graphs — gets branching, merging, time-travel, typed diffs, conflict
resolution, and semantic versioning for free. The engine handles the DAG;
you handle the meaning.
The protocol
MuseDomainPlugin is a @runtime_checkable Protocol —
duck-typed, not inherited. Your plugin is a plain class that satisfies the
interface; no base class required, no registration decorator. The engine checks
isinstance(plugin, MuseDomainPlugin) at load time, then calls the
six methods throughout its lifecycle.
Two optional extension protocols layer additional capabilities on top. Implementing them unlocks engine features automatically:
| Protocol | Extra method | Unlocks |
|---|---|---|
MuseDomainPlugin |
— | Full VCS: branch, merge, log, diff, checkout, push/pull |
AddressedMergePlugin |
merge_ops() |
Address-keyed Map merge — ops at different addresses commute automatically |
HarmonyPlugin |
conflict_fingerprint() + similarity() |
Semantic conflict fingerprinting — Tier 3 fuzzy replay |
Six methods
from muse.domain import (
MuseDomainPlugin, LiveState, StateSnapshot,
StateDelta, MergeResult, DriftReport, DomainSchema,
)
import pathlib
class MuseDomainPlugin(Protocol):
def snapshot(self, live_state: LiveState) -> StateSnapshot:
"""Capture current state as a content-addressable SnapshotManifest.
live_state is a Path (working tree) or an existing SnapshotManifest.
Walk the domain's files, hash each one, return the manifest.
Must honour .museignore — call ignore_filter(path) before adding.
"""
def diff(
self,
base: StateSnapshot,
target: StateSnapshot,
*,
repo_root: pathlib.Path | None = None,
) -> StateDelta:
"""Compute the minimal StructuredDelta from base → target.
StateDelta = {"domain": str, "ops": list[DomainOp], "summary": str,
"sem_ver_bump": "none"|"patch"|"minor"|"major",
"breaking_changes": list[str]}
"""
def merge(
self,
base: StateSnapshot,
left: StateSnapshot,
right: StateSnapshot,
*,
repo_root: pathlib.Path | None = None,
) -> MergeResult:
"""Three-way merge. base is the common ancestor.
MergeResult = {"merged": SnapshotManifest, "conflicts": list[str]}
conflicts is a list of paths that could not be auto-merged.
Consult .museattributes for per-path merge strategy overrides.
"""
def drift(
self,
committed: StateSnapshot,
live: LiveState,
) -> DriftReport:
"""Detect uncommitted changes between HEAD snapshot and live working tree.
DriftReport = dataclass(has_drift: bool, summary: str, delta: StateDelta | None)
Called by `muse status` and `muse drift` commands.
"""
def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
"""Apply a delta to reconstruct historical state during checkout.
For most file-based domains this is a no-op that returns live_state
unchanged — the engine handles file restoration via the object store.
Override when your domain needs post-processing (e.g. recompilation).
"""
def schema(self) -> DomainSchema:
"""Declare the domain's data structure.
Drives diff algorithm selection per dimension, merge mode,
and the domain registry UI at /domains.
"""
Type aliases
| Alias | Concrete type | Notes |
|---|---|---|
LiveState | Path | SnapshotManifest | Working tree path or in-memory snapshot |
StateSnapshot | SnapshotManifest | {"files": dict[str,str], "domain": str, "directories": list[str]} |
StateDelta | StructuredDelta | Typed ops list + summary + semver hint |
MergeResult | TypedDict | {"merged": SnapshotManifest, "conflicts": list[str]} |
DriftReport | dataclass | has_drift: bool, summary: str, delta: StateDelta | None |
What every plugin gets for free
Implement the six-method protocol and the core engine delivers four advanced capabilities automatically — no extra work required per domain.
Unlike Git's blob diffs, Muse deltas are typed objects:
InsertOp, ReplaceOp, DeleteOp — each
carrying the address, before/after content IDs, and affected dimension.
Machine-readable with muse read --json.
{
"op": "replace",
"address": "shared-state.mid",
"old_content_id": "sha256:a1b2c3d4e5f67890…",
"new_content_id": "sha256:e5f6a7b8c9d01234…"
}
Each plugin's schema() declares its dimensions and merge mode.
The engine uses this to select the right diff algorithm per dimension and
to surface only the dimensions that actually conflict.
Plugins implementing AddressedMergePlugin use address-keyed Map merge. Operations at different addresses commute automatically — only operations on the same address with incompatible intent surface a conflict.
Plugins implementing HarmonyPlugin provide
conflict_fingerprint() and similarity().
Harmony's four-tier engine replays known resolutions automatically —
Tier 3 matches conflicts by semantic shape, not blob identity.
A human-verified resolution confidence of 1.0 gates the replay.
Tier 1 — Policy declarative rule fires on path pattern
Tier 2 — Exact replay blob fingerprint matches a saved resolution
Tier 3 — Semantic similarity() score ≥ threshold → fuzzy replay
Tier 4 — Escalate create hub issue; flag for human or agent
DomainSchema
The schema declaration tells the engine how your domain is structured: what dimensions exist, whether they merge independently, what algorithm to use, and what merge mode applies. It also drives the domain registry UI — the description, dimension list, and tags displayed on /domains.
{
"domain": "identity",
"description": "Cryptographic identity graph — humans, agents, orgs.",
"schema_version": "1.0.0",
"merge_mode": "three_way", # "three_way" | "crdt"
"dimensions": [
{
"name": "identities",
"schema": {"kind": "set"}, # "set" | "sequence" | "map" | "scalar"
"independent_merge": True, # this dim merges without blocking others
},
{
"name": "relationships",
"schema": {"kind": "set"},
"independent_merge": True,
},
],
}
| Field | Values | Effect |
|---|---|---|
merge_mode | three_way | Standard common-ancestor merge; merge() called. If the plugin also implements AddressedMergePlugin, merge_ops() is called instead for address-keyed Map merge. |
merge_mode | crdt | Convergent join; no common ancestor needed |
schema.kind | set | Unordered collection; insert/delete ops only |
schema.kind | sequence | Ordered list; insert/delete/move ops; OT safe |
schema.kind | map | Key-value; replace/mutate ops |
schema.kind | scalar | Single value; replace only; last-write-wins |
independent_merge | True | This dimension merges without waiting for siblings |
Five algebras. One typed result.
The engine selects the diff algorithm per dimension from your plugin's
schema(). You declare the shape — the engine handles identity,
diffing, and merge selection automatically.
Typed delta algebra
Unlike Git's blob diffs, Muse deltas are typed objects. Every change is an op with a known shape: the address it targets, before/after content IDs, and the dimension it belongs to. This makes deltas machine-readable, replayable, and composable — the foundation for address-keyed Map merge and semantic conflict resolution.
# Ordered-sequence domains (MIDI note-level): position required
InsertOp = {"op": "insert", "address": str, "content_id": str, "position": int}
DeleteOp = {"op": "delete", "address": str, "content_id": str, "position": int}
# Name-addressed (unordered) domains: no position field — address IS the identity
AddressedInsertOp = {"op": "insert", "address": str, "content_id": str,
"content_summary": str}
AddressedDeleteOp = {"op": "delete", "address": str, "content_id": str,
"content_summary": str}
# Reposition within an ordered sequence — commutes with non-overlapping moves
MoveOp = {"op": "move", "address": str, "from_position": int, "to_position": int}
# Atomic leaf-level change; old_content_id enables conflict detection
ReplaceOp = {"op": "replace", "address": str,
"old_content_id": str, "new_content_id": str}
# Entity modification: id + named field mutations (for structured records)
MutateOp = {"op": "mutate", "address": str,
"entity_id": str, "mutations": dict[str, Any]}
# Container modification: path + nested child ops (subtree patch)
PatchOp = {"op": "patch", "address": str, "child_ops": list[DomainOp]}
# Rename any first-class entity — directory, file, or symbol (address changed, content unchanged)
RenameOp = {"op": "rename", "address": str, "from_address": str}
{
"domain": "identity",
"summary": "Added 1 identity, 2 relationships",
"sem_ver_bump": "minor",
"ops": [
{
"op": "insert",
"address": "identities/claude-code.json",
"content_id": "sha256:a1b2c3...",
"position": null
},
{
"op": "replace",
"address": "identities/gabriel.json",
"old_content_id": "sha256:d4e5f6...",
"new_content_id": "sha256:7890ab..."
}
]
}
Address-keyed commutativity
When AddressedMergePlugin.merge_ops() is called, the engine
passes both branches' op lists. Operations at different addresses
commute automatically — both are applied, no conflict. Only operations at the
same address with incompatible intent surface as a conflict:
left: InsertOp("identities/alice.json") ─┐
right: InsertOp("identities/bob.json") ─┘ → auto-merge (different addresses)
left: ReplaceOp("identities/gabriel.json", v1→v2) ─┐
right: ReplaceOp("identities/gabriel.json", v1→v3) ─┘ → CONFLICT (same addr, different targets)
left: ReplaceOp("identities/gabriel.json", v1→v2) ─┐
right: ReplaceOp("identities/gabriel.json", v1→v2) ─┘ → auto-merge (consensus: identical op)
Addressed-merge example — conflict and resolution
Two agents check out the same commit and both edit
config/app.json. They diverge on MAX_CONNECTIONS
and converge on an unrelated key. Here is the full lifecycle: deltas, merge
call, conflict surfacing, manual resolution, harmony learning.
Base state
{
"MAX_CONNECTIONS": 10,
"TIMEOUT_MS": 3000,
"LOG_LEVEL": "info"
}
Agent A's delta (feat/scale-up)
{
"domain": "json-docs",
"ops": [
{
"op": "replace",
"address": "config/app.json",
"old_content_id": "sha256:base00…",
"new_content_id": "sha256:left01…" // MAX_CONNECTIONS: 10 → 20
}
],
"summary": "scale MAX_CONNECTIONS to 20"
}
Agent B's delta (feat/high-load)
{
"domain": "json-docs",
"ops": [
{
"op": "replace",
"address": "config/app.json",
"old_content_id": "sha256:base00…",
"new_content_id": "sha256:right02…" // MAX_CONNECTIONS: 10 → 50, TIMEOUT_MS: 3000 → 5000
}
],
"summary": "high-load tuning: MAX_CONNECTIONS 50, TIMEOUT_MS 5000"
}
Merge call and conflict
When dev merges both branches, merge_ops() receives both op lists.
Both ops target the same address (config/app.json) with the same
old_content_id but different new_content_id values —
an irreconcilable divergence. The engine surfaces a conflict:
muse merge feat/high-load
Merging feat/high-load into dev… ✔ auto-merged: 0 paths ✘ conflicts: 1 path config/app.json (both branches modified from same base) Merge incomplete. Resolve conflicts then: muse commit -m "merge: …"
Manual resolution
Edit config/app.json to the desired final value, stage it, and
commit. Harmony records the resolution automatically with
confidence=1.0 (human-verified). The next time this exact conflict
recurs — same old_content_id, same two branches — Harmony
replays the resolution without human input.
# Manually set MAX_CONNECTIONS: 50, TIMEOUT_MS: 5000 (take B's tuning, accept A's intent)
# ... edit config/app.json ...
muse code add config/app.json
muse commit -m "merge: resolve MAX_CONNECTIONS conflict — use 50 for high-load" \
--agent-id claude-code --model-id claude-sonnet-4-6 --sign
committed sha256:9e21b8… ✔ harmony: recorded resolution for 'config/app.json' (confidence 1.0, human-verified)
JsonDocPlugin above uses merge_mode: three_way
and calls merge(). Plugins that also implement
AddressedMergePlugin have merge_ops() called instead,
which returns op-level conflict descriptions — enabling the engine to auto-merge
ops at different addresses even within the same file. The example above
is simplified for clarity.
Extension protocols
AddressedMergePlugin
class AddressedMergePlugin(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:
"""Address-keyed Map merge. Ops at different addresses commute; ops at the
same address with incompatible intent produce a conflict entry."""
HarmonyPlugin
class HarmonyPlugin(MuseDomainPlugin, Protocol):
def conflict_fingerprint(
self,
path: str,
ours_id: str, # "sha256:..." of ours blob
theirs_id:str, # "sha256:..." of theirs blob
repo_root:pathlib.Path,
) -> str: # 64-char hex semantic fingerprint
"""Produce a fingerprint that captures the conflict's structural shape
independent of timestamps, signatures, or formatting noise.
Two conflicts that represent the same logical change (e.g. the same
edge being added with different timestamps) must return the same
fingerprint — enabling Harmony Tier 3 to replay the resolution."""
def similarity(self, fp_a: str, fp_b: str) -> float:
"""Score structural similarity between two conflict fingerprints.
Return value in [0.0, 1.0]. Out-of-range values are clamped.
DefaultPlugin returns 1.0 for identical fps, 0.0 otherwise."""
The identity domain's conflict_fingerprint() hashes the
(from_handle, edge_type, to_handle) triple from a relationship
file, ignoring signatures and timestamps entirely. Two pushes that add the
same logical edge with different authorization timestamps produce the same
fingerprint and replay the same resolution — without human intervention.
Shipped domains
Three domains ship with Muse. All three are fully active, with typed deltas,
structured merge, and .museattributes strategy control.
code
Source-code versioning with symbol-level addressed merge.
Tree-sitter parses 11 languages into ASTs; functions, classes, and imports
merge independently. Two branches that each add a new function auto-merge.
Two branches that both modify the same function body produce a conflict
at the symbol level, not the line level — the conflict description names
the function, not a line range. Dimensions: functions,
classes, imports, variables,
expressions.
identity
Identity graph versioning with DAG invariant enforcement.
Two dimensions — identities (set) and relationships
(set) — both merge independently. New identities and new relationships added
on separate branches auto-merge. Same-file modifications conflict. During
merge, I1 acyclicity is re-enforced on the merged relationship set: any new
edge that would introduce a cycle becomes a conflict rather than being silently
applied — the graph is never left in a cyclic state.
mist
Content-addressed artifact hosting with signed provenance.
A mist repo stores one artifact per file, keyed by the first 12 characters
of the base-58 SHA-256 of its bytes — the filename is the identity.
Any artifact type is first-class: MIDI, Solidity ABIs, JSON schemas, code,
images, or arbitrary binary. Every artifact carries an Ed25519 author
signature; AI-produced artifacts also embed agent_id and
model_id for full provenance. Because MistPlugin
satisfies MuseDomainPlugin, all Muse CLI commands — status,
diff, merge, log — work on mist repos without any engine changes.
See Phase 12: Mist Domain for the full reference.
Minimal plugin
This is a complete, working domain plugin for a flat directory of JSON
documents. It implements the full MuseDomainPlugin protocol —
six methods, under 80 lines. No base class, no decorator.
from __future__ import annotations
import hashlib, json, pathlib
from muse.domain import (
DomainSchema, DriftReport, LiveState, MergeResult,
MuseDomainPlugin, SnapshotManifest, StateDelta, StateSnapshot,
)
def _sha256(data: bytes) -> str:
return "sha256:" + hashlib.sha256(data).hexdigest()
class JsonDocPlugin:
"""Version a flat directory of *.json documents."""
def schema(self) -> DomainSchema:
return {
"domain": "json-docs",
"description": "Flat directory of JSON documents.",
"schema_version": "1.0.0",
"merge_mode": "three_way",
"dimensions": [{
"name": "documents",
"schema": {"kind": "set"},
"independent_merge": True,
}],
}
def snapshot(self, live_state: LiveState) -> StateSnapshot:
root = pathlib.Path(live_state) if isinstance(live_state, (str, pathlib.Path)) else None
if root is None:
return live_state # already a SnapshotManifest
files = {}
for p in sorted(root.glob("**/*.json")):
rel = p.relative_to(root).as_posix()
files[rel] = _sha256(p.read_bytes())
return SnapshotManifest(files=files, domain="json-docs", directories=[])
def diff(self, base: StateSnapshot, target: StateSnapshot, **_) -> StateDelta:
b, t = base["files"], target["files"]
ops = []
for path in t.keys() - b.keys():
ops.append({"op": "insert", "address": path, "content_id": t[path], "position": None})
for path in b.keys() - t.keys():
ops.append({"op": "delete", "address": path, "content_id": b[path], "position": None})
for path in b.keys() & t.keys():
if b[path] != t[path]:
ops.append({"op": "replace", "address": path,
"old_content_id": b[path], "new_content_id": t[path]})
adds = sum(1 for o in ops if o["op"] == "insert")
return {"domain": "json-docs", "ops": ops,
"summary": f"{adds} added, {len(ops)-adds} changed",
"sem_ver_bump": "minor" if adds else "patch"}
def merge(self, base, left, right, **_) -> MergeResult:
b, l, r = base["files"], left["files"], right["files"]
merged, conflicts = dict(b), []
for path in l.keys() | r.keys() | b.keys():
lv, rv, bv = l.get(path), r.get(path), b.get(path)
if lv == rv: merged[path] = lv # consensus / unchanged
elif lv is None: merged.pop(path, None) # deleted on left
elif rv is None: merged.pop(path, None) # deleted on right
elif lv == bv: merged[path] = rv # only right changed
elif rv == bv: merged[path] = lv # only left changed
else: conflicts.append(path) # both changed differently
merged = {k: v for k, v in merged.items() if v is not None}
return {"merged": SnapshotManifest(files=merged, domain="json-docs", directories=[]),
"conflicts": conflicts}
def drift(self, committed, live) -> DriftReport:
current = self.snapshot(live)
delta = self.diff(committed, current)
return DriftReport(has_drift=bool(delta["ops"]), summary=delta["summary"], delta=delta)
def apply(self, delta, live_state) -> LiveState:
return live_state # engine handles file restoration via object store
.museattributes
.museattributes is a gitattributes-style file that lets you
override the merge strategy for specific paths or patterns — without touching
plugin code. The engine reads it before calling merge() and
adjusts per-path behavior. Useful for lock files, generated code, or any
file where "ours always wins" is the right policy.
# Pattern Strategy
*.lock merge=ours
generated/** merge=ours
tracks/master.mid merge=theirs # always take incoming master
config/schema.json merge=union # merge JSON keys additively
*.mid merge=domain # delegate to MidiPlugin (default)
| Strategy | Behaviour |
|---|---|
domain | Delegate to the active domain plugin (default) |
ours | Always take our version; no conflict raised |
theirs | Always take their version; no conflict raised |
union | Additive merge where possible; conflict if destructive |
binary | Treat as opaque blob; conflict on any difference |
# Query the resolved strategy for a path
muse check-attr tracks/drums.mid --json
# List all attribute rules
muse attributes list --json
# Validate the .museattributes file
muse attributes validate --json
Should I write a domain plugin?
Most projects don't need a custom plugin. Work through this tree before writing any code:
Do you need Muse to understand the structure of your files? ├── No → Use the code or json-docs domain. Add .museattributes │ rules for files that need custom merge strategies. Done. │ └── Yes → Do your files have multiple independent dimensions (e.g. notes vs. tempo in a MIDI file)? │ ├── No → Implement MuseDomainPlugin with merge_mode: three_way. │ Override merge() for your custom merge semantics. │ ~50 lines. See the JsonDocPlugin example. │ └── Yes → Can edits to different dimensions always merge without conflicting each other? │ ├── Yes → Use independent_merge: True per dimension │ in DomainSchema. The engine parallelises │ dimension merges automatically. │ └── No → Implement AddressedMergePlugin. Override merge_ops() for address-keyed Map merge. Adds ~30 lines. │ └── Also want Harmony Tier 3 (semantic conflict replay)? └── Implement HarmonyPlugin too. Add conflict_fingerprint() + similarity(). ~20 more lines.
| Your situation | What to implement | Approx. lines |
|---|---|---|
| File types Muse doesn't know about, custom merge policy | MuseDomainPlugin (three_way) |
~50 |
| Structured records with independent sub-dimensions | MuseDomainPlugin + independent_merge: True |
~50 |
| Op-level merging — auto-resolve edits to different addresses in same file | AddressedMergePlugin |
~80 |
| Semantic conflict memory — teach Harmony to recognize conflict shapes | HarmonyPlugin |
~20 additional |
| Just need "ours always wins" for a specific file type | .museattributes rule, no plugin |
1 line |
Registry
Domains are registered in muse/plugins/registry.py. The registry
is a plain dict keyed by domain name. The active domain for a repo is stored
in .muse/repo.json and resolved at startup.
Hash-derived domain integers
Every domain name maps to a 31-bit integer used as the second level of the
SLIP-0010 HD path (m/1075233755'/domain'/…).
The mapping is deterministic and collision-resistant:
import hashlib
def domain_index(name: str) -> int:
"""Return the 31-bit HD path integer for a domain name string."""
digest = hashlib.sha256(name.encode()).digest()
return int.from_bytes(digest[:4], "big") & 0x7FFFFFFF
| Domain name | HD path integer |
|---|---|
muse/identity | 1660078172 |
muse/payments | 284229149 |
muse/code | 678195575 |
muse/mist | 915186137 |
muse/music | 1755707987 |
muse/midi | 1444628350 |
muse/prose | 1658731548 |
muse/blockchain | 1556829714 |
muse/generic | 2023564266 |
# Compute the HD path integer for any domain name
muse domain index muse/identity --json
# → {"name": "muse/identity", "domain_int": 1660078172, ...}
# Reverse-lookup: name from integer
muse domain lookup 1660078172 --json
# → {"domain_int": 1660078172, "name": "muse/identity", ...}
# List all known first-party domains
muse domain list --json
# Verify the integer matches the name (useful in CI)
muse domain check muse/identity 1660078172 --json
Plugin registry
from muse.plugins.code.plugin import CodePlugin
from muse.plugins.identity.plugin import IdentityPlugin
from muse.plugins.mist.plugin import MistPlugin
from my_domain.plugin import JsonDocPlugin # your plugin
_REGISTRY: dict[str, MuseDomainPlugin] = {
"code": CodePlugin(),
"identity": IdentityPlugin(),
"mist": MistPlugin(),
"json-docs": JsonDocPlugin(), # registered here
}
# Init a repo with your domain
muse init --domain json-docs
# Inspect the active plugin
muse domain-info --json
# List all registered domains
muse domains --json
# See API surface of the active domain
muse api-surface --json
# Decode an HD path to human-readable labels
muse path annotate "m/1075233755'/1660078172'/0'/0'/0'/0'" --json
GET /api/musehub/domains
and browsable at /domains. Domains registered on the hub
can declare a viewer_type that drives how the hub renders files
in that domain — enabling custom tree views, diff renderers, and conflict
UIs per domain without touching hub code.