muse-protocol.md
markdown
| 1 | # MuseDomainPlugin Protocol — Language-Agnostic Specification |
| 2 | |
| 3 | > **Status:** Canonical · **Version:** v1.0 |
| 4 | > **Audience:** Anyone implementing a Muse domain plugin in any language. |
| 5 | |
| 6 | --- |
| 7 | |
| 8 | ## 0. Purpose |
| 9 | |
| 10 | This document specifies the five-method contract a domain plugin must satisfy to |
| 11 | integrate with the Muse VCS engine. It is intentionally language-agnostic. |
| 12 | |
| 13 | Muse provides the DAG, object store, branching, lineage, merge state machine, log, |
| 14 | and CLI. A plugin provides domain knowledge. This document defines the boundary |
| 15 | between them. |
| 16 | |
| 17 | --- |
| 18 | |
| 19 | ## 1. Design Principles |
| 20 | |
| 21 | 1. **Plugins are pure transformations.** A plugin method takes state in, returns state |
| 22 | out. Side effects (writing to disk, calling APIs) belong to the CLI layer, not |
| 23 | the plugin. |
| 24 | 2. **All state is JSON-serializable.** Snapshots must be serializable to a |
| 25 | content-addressable string. No opaque blobs inside snapshot values. |
| 26 | 3. **Content-addressed identity.** The same state must always produce the same |
| 27 | snapshot. Snapshots are compared by their SHA-256 digest — not by object identity. |
| 28 | 4. **Idempotent writes.** Writing an object or snapshot that already exists is a |
| 29 | no-op. The store never overwrites existing content. |
| 30 | 5. **Conflicts are data, not exceptions.** A conflicted merge returns a `MergeResult` |
| 31 | with a non-empty `conflicts` list. It does not raise. |
| 32 | 6. **Drift is always relative.** `drift()` compares committed state against live |
| 33 | state. It never modifies either. |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## 2. Type Definitions |
| 38 | |
| 39 | All types use Python as the reference notation. Implementations in other languages |
| 40 | should map to equivalent constructs. |
| 41 | |
| 42 | ```python |
| 43 | # A workspace-relative path mapped to its SHA-256 content digest. |
| 44 | # Plugins are free to add top-level keys alongside "files" and "domain". |
| 45 | StateSnapshot = dict # must contain "files": dict[str, str] and "domain": str |
| 46 | |
| 47 | # The "live" input to snapshot() and drift(). |
| 48 | # Either a filesystem path to the working directory, |
| 49 | # or an existing StateSnapshot (used for in-memory operations). |
| 50 | LiveState = Path | StateSnapshot |
| 51 | |
| 52 | # Output of diff(): three sorted lists of workspace-relative paths. |
| 53 | StateDelta = dict # must contain "added", "removed", "modified": list[str] and "domain": str |
| 54 | |
| 55 | # Output of merge(): the reconciled snapshot + paths that conflicted. |
| 56 | # "conflicts" is a list of workspace-relative paths that could not be |
| 57 | # auto-resolved. Empty list means the merge was clean. |
| 58 | MergeResult = dataclass(merged: StateSnapshot, conflicts: list[str]) |
| 59 | |
| 60 | # Output of drift(): summary of how live state diverges from committed state. |
| 61 | DriftReport = dataclass(has_drift: bool, summary: str, delta: StateDelta) |
| 62 | ``` |
| 63 | |
| 64 | --- |
| 65 | |
| 66 | ## 3. The Five Methods |
| 67 | |
| 68 | ### 3.1 `snapshot(live_state: LiveState) → StateSnapshot` |
| 69 | |
| 70 | Capture the current live state as a serializable, content-addressable snapshot. |
| 71 | |
| 72 | **Contract:** |
| 73 | - The return value MUST be JSON-serializable. |
| 74 | - The return value MUST contain a `"files"` key mapping workspace-relative path |
| 75 | strings to their SHA-256 hex digests. |
| 76 | - The return value MUST contain a `"domain"` key matching the plugin's domain name. |
| 77 | - Given identical input, the output MUST be identical (deterministic). |
| 78 | - If `live_state` is already a `StateSnapshot` dict, return it unchanged. |
| 79 | |
| 80 | **Called by:** `muse commit`, `muse stash` |
| 81 | |
| 82 | --- |
| 83 | |
| 84 | ### 3.2 `diff(base: StateSnapshot, target: StateSnapshot) → StateDelta` |
| 85 | |
| 86 | Compute the minimal delta between two snapshots. |
| 87 | |
| 88 | **Contract:** |
| 89 | - Return MUST contain `"added"`: sorted list of paths present in `target` but not `base`. |
| 90 | - Return MUST contain `"removed"`: sorted list of paths present in `base` but not `target`. |
| 91 | - Return MUST contain `"modified"`: sorted list of paths present in both with different digests. |
| 92 | - Return MUST contain `"domain"` matching the plugin's domain name. |
| 93 | - All three lists MUST be sorted. |
| 94 | - A path that appears in `added` MUST NOT appear in `removed` or `modified`. |
| 95 | |
| 96 | **Called by:** `muse diff`, `muse checkout` |
| 97 | |
| 98 | --- |
| 99 | |
| 100 | ### 3.3 `merge(base: StateSnapshot, left: StateSnapshot, right: StateSnapshot) → MergeResult` |
| 101 | |
| 102 | Three-way merge two divergent state lines against a common ancestor. |
| 103 | |
| 104 | **Contract:** |
| 105 | - `base` is the common ancestor (merge base). |
| 106 | - `left` is the current branch's snapshot (ours). |
| 107 | - `right` is the incoming branch's snapshot (theirs). |
| 108 | - `result.merged` MUST be a valid `StateSnapshot`. |
| 109 | - `result.conflicts` MUST be a list of workspace-relative path strings. |
| 110 | - An empty list means the merge was clean. |
| 111 | - Paths in `result.conflicts` MUST also appear in `result.merged` (placeholder state). |
| 112 | - **Consensus deletion** (both sides deleted the same path) is NOT a conflict. |
| 113 | - This method MUST NOT raise on conflict — it returns the conflict list instead. |
| 114 | |
| 115 | **Called by:** `muse merge`, `muse cherry-pick` |
| 116 | |
| 117 | --- |
| 118 | |
| 119 | ### 3.4 `drift(committed: StateSnapshot, live: LiveState) → DriftReport` |
| 120 | |
| 121 | Detect how far the live state has diverged from the last committed snapshot. |
| 122 | |
| 123 | **Contract:** |
| 124 | - `result.has_drift` is `True` if and only if `delta` is non-empty. |
| 125 | - `result.summary` is a human-readable string (e.g. `"2 added, 1 modified"` |
| 126 | or `"working tree clean"`). |
| 127 | - `result.delta` is a valid `StateDelta`. |
| 128 | - This method MUST NOT modify any state. |
| 129 | |
| 130 | **Called by:** `muse status` |
| 131 | |
| 132 | --- |
| 133 | |
| 134 | ### 3.5 `apply(delta: StateDelta, live_state: LiveState) → LiveState` |
| 135 | |
| 136 | Apply a delta to produce a new live state. Serves as the domain-level |
| 137 | post-checkout hook. |
| 138 | |
| 139 | **Contract:** |
| 140 | - When `live_state` is a filesystem `Path`: the caller has already applied the |
| 141 | delta physically (removed deleted files, restored added/modified from the object |
| 142 | store). The plugin SHOULD rescan the directory and return the authoritative new |
| 143 | state as a `StateSnapshot`. |
| 144 | - When `live_state` is a `StateSnapshot` dict: apply removals to the in-memory dict. |
| 145 | Added/modified paths SHOULD be noted as limitations — the delta does not carry |
| 146 | content hashes, so the caller must supply them through another path. |
| 147 | - The return value MUST be a valid `LiveState`. |
| 148 | |
| 149 | **Called by:** `muse checkout` |
| 150 | |
| 151 | --- |
| 152 | |
| 153 | ## 4. Snapshot Format (Normative) |
| 154 | |
| 155 | The minimum required shape for a `StateSnapshot`: |
| 156 | |
| 157 | ```json |
| 158 | { |
| 159 | "files": { |
| 160 | "path/to/file-a": "sha256-hex-64-chars", |
| 161 | "path/to/file-b": "sha256-hex-64-chars" |
| 162 | }, |
| 163 | "domain": "my_domain_name" |
| 164 | } |
| 165 | ``` |
| 166 | |
| 167 | Plugins MAY add additional top-level keys for domain-specific metadata: |
| 168 | |
| 169 | ```json |
| 170 | { |
| 171 | "files": { ... }, |
| 172 | "domain": "music", |
| 173 | "tempo_bpm": 120, |
| 174 | "key": "Am" |
| 175 | } |
| 176 | ``` |
| 177 | |
| 178 | Additional keys MUST be JSON-serializable. The core engine ignores them; they |
| 179 | are available to domain-specific CLI commands via `plugin.snapshot()`. |
| 180 | |
| 181 | --- |
| 182 | |
| 183 | ## 5. Naming Conventions |
| 184 | |
| 185 | | Scope | Convention | |
| 186 | |---|---| |
| 187 | | Wire format (JSON) | `camelCase` | |
| 188 | | Python internals | `snake_case` | |
| 189 | | Plugin domain name in `repo.json` | `snake_case` | |
| 190 | | Workspace-relative paths in snapshots | POSIX forward-slash separators | |
| 191 | |
| 192 | --- |
| 193 | |
| 194 | ## 6. Implementing a Plugin |
| 195 | |
| 196 | Minimum viable implementation in Python: |
| 197 | |
| 198 | ```python |
| 199 | from muse.domain import ( |
| 200 | DeltaManifest, DriftReport, LiveState, MergeResult, |
| 201 | MuseDomainPlugin, SnapshotManifest, StateDelta, StateSnapshot, |
| 202 | ) |
| 203 | |
| 204 | class MyDomainPlugin: |
| 205 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 206 | if isinstance(live_state, pathlib.Path): |
| 207 | files = { |
| 208 | f.relative_to(live_state).as_posix(): _hash(f) |
| 209 | for f in sorted(live_state.rglob("*")) |
| 210 | if f.is_file() |
| 211 | } |
| 212 | return SnapshotManifest(files=files, domain="my_domain") |
| 213 | return live_state # already a snapshot dict |
| 214 | |
| 215 | def diff(self, base: StateSnapshot, target: StateSnapshot) -> StateDelta: |
| 216 | b, t = base["files"], target["files"] |
| 217 | return DeltaManifest( |
| 218 | domain="my_domain", |
| 219 | added=sorted(set(t) - set(b)), |
| 220 | removed=sorted(set(b) - set(t)), |
| 221 | modified=sorted(p for p in set(b) & set(t) if b[p] != t[p]), |
| 222 | ) |
| 223 | |
| 224 | def merge(self, base, left, right) -> MergeResult: |
| 225 | # ... domain-specific reconciliation ... |
| 226 | |
| 227 | def drift(self, committed, live) -> DriftReport: |
| 228 | live_snap = self.snapshot(live) |
| 229 | delta = self.diff(committed, live_snap) |
| 230 | has_drift = any([delta["added"], delta["removed"], delta["modified"]]) |
| 231 | return DriftReport(has_drift=has_drift, summary="...", delta=delta) |
| 232 | |
| 233 | def apply(self, delta, live_state) -> LiveState: |
| 234 | if isinstance(live_state, pathlib.Path): |
| 235 | return self.snapshot(live_state) |
| 236 | files = dict(live_state["files"]) |
| 237 | for p in delta["removed"]: |
| 238 | files.pop(p, None) |
| 239 | return SnapshotManifest(files=files, domain="my_domain") |
| 240 | ``` |
| 241 | |
| 242 | See `muse/plugins/music/plugin.py` for the complete reference implementation. |
| 243 | |
| 244 | --- |
| 245 | |
| 246 | ## 7. Invariants the Core Engine Relies On |
| 247 | |
| 248 | The core engine assumes: |
| 249 | |
| 250 | 1. `snapshot(snapshot_dict)` returns the dict unchanged. |
| 251 | 2. `diff(s, s)` returns empty `added`, `removed`, `modified` for identical snapshots. |
| 252 | 3. `merge(base, s, s)` returns `s` with an empty `conflicts` list. |
| 253 | 4. `drift(s, path_to_workdir_matching_s)` returns `has_drift=False`. |
| 254 | 5. Object IDs in `StateSnapshot["files"]` are valid SHA-256 hex strings (64 chars). |
| 255 | |
| 256 | Violating these invariants will cause incorrect behavior in `checkout`, `status`, |
| 257 | and merge state detection. |