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 + conflict + strategy metadata. |
| 56 | # "conflicts" — workspace-relative paths that could not be auto-resolved. |
| 57 | # Empty list means the merge was clean. |
| 58 | # "applied_strategies" — path → strategy string applied by .museattributes |
| 59 | # (e.g. {"drums/kick.mid": "ours"}). Empty if no rules fired. |
| 60 | # "dimension_reports" — path → {dimension: winner} for files that went through |
| 61 | # dimension-level merge (e.g. {"keys.mid": {"notes": "left"}}). |
| 62 | MergeResult = dataclass( |
| 63 | merged: StateSnapshot, |
| 64 | conflicts: list[str], |
| 65 | applied_strategies: dict[str, str], |
| 66 | dimension_reports: dict[str, dict[str, str]], |
| 67 | ) |
| 68 | |
| 69 | # Output of drift(): summary of how live state diverges from committed state. |
| 70 | DriftReport = dataclass(has_drift: bool, summary: str, delta: StateDelta) |
| 71 | ``` |
| 72 | |
| 73 | --- |
| 74 | |
| 75 | ## 3. The Five Methods |
| 76 | |
| 77 | ### 3.1 `snapshot(live_state: LiveState) → StateSnapshot` |
| 78 | |
| 79 | Capture the current live state as a serializable, content-addressable snapshot. |
| 80 | |
| 81 | **Contract:** |
| 82 | - The return value MUST be JSON-serializable. |
| 83 | - The return value MUST contain a `"files"` key mapping workspace-relative path |
| 84 | strings to their SHA-256 hex digests. |
| 85 | - The return value MUST contain a `"domain"` key matching the plugin's domain name. |
| 86 | - Given identical input, the output MUST be identical (deterministic). |
| 87 | - If `live_state` is already a `StateSnapshot` dict, return it unchanged. |
| 88 | |
| 89 | **Called by:** `muse commit`, `muse stash` |
| 90 | |
| 91 | --- |
| 92 | |
| 93 | ### 3.2 `diff(base: StateSnapshot, target: StateSnapshot) → StateDelta` |
| 94 | |
| 95 | Compute the minimal delta between two snapshots. |
| 96 | |
| 97 | **Contract:** |
| 98 | - Return MUST contain `"added"`: sorted list of paths present in `target` but not `base`. |
| 99 | - Return MUST contain `"removed"`: sorted list of paths present in `base` but not `target`. |
| 100 | - Return MUST contain `"modified"`: sorted list of paths present in both with different digests. |
| 101 | - Return MUST contain `"domain"` matching the plugin's domain name. |
| 102 | - All three lists MUST be sorted. |
| 103 | - A path that appears in `added` MUST NOT appear in `removed` or `modified`. |
| 104 | |
| 105 | **Called by:** `muse diff`, `muse checkout` |
| 106 | |
| 107 | --- |
| 108 | |
| 109 | ### 3.3 `merge(base, left, right: StateSnapshot, *, repo_root: Path | None = None) → MergeResult` |
| 110 | |
| 111 | Three-way merge two divergent state lines against a common ancestor. |
| 112 | |
| 113 | **Contract:** |
| 114 | - `base` is the common ancestor (merge base). |
| 115 | - `left` is the current branch's snapshot (ours). |
| 116 | - `right` is the incoming branch's snapshot (theirs). |
| 117 | - `repo_root`, when provided, is the filesystem root of the repository. |
| 118 | Implementations SHOULD use it to load `.museattributes` and apply |
| 119 | file-level or dimension-level merge strategies before falling back to |
| 120 | conflict reporting. |
| 121 | - `result.merged` MUST be a valid `StateSnapshot`. |
| 122 | - `result.conflicts` MUST be a list of workspace-relative path strings. |
| 123 | - An empty list means the merge was clean. |
| 124 | - Paths in `result.conflicts` MUST also appear in `result.merged` (placeholder state). |
| 125 | - `result.applied_strategies` maps paths where a `.museattributes` rule overrode |
| 126 | the default conflict behaviour to the strategy string that was used. |
| 127 | Plugins SHOULD populate this for observability; it MAY be empty. |
| 128 | - `result.dimension_reports` maps paths that received dimension-level merge to |
| 129 | a `{dimension: winner}` dict for each resolved dimension. |
| 130 | Plugins that do not support dimension merge MAY always return `{}`. |
| 131 | - **Consensus deletion** (both sides deleted the same path) is NOT a conflict. |
| 132 | - This method MUST NOT raise on conflict — it returns the conflict list instead. |
| 133 | |
| 134 | **Called by:** `muse merge`, `muse cherry-pick` |
| 135 | |
| 136 | --- |
| 137 | |
| 138 | ### 3.4 `drift(committed: StateSnapshot, live: LiveState) → DriftReport` |
| 139 | |
| 140 | Detect how far the live state has diverged from the last committed snapshot. |
| 141 | |
| 142 | **Contract:** |
| 143 | - `result.has_drift` is `True` if and only if `delta` is non-empty. |
| 144 | - `result.summary` is a human-readable string (e.g. `"2 added, 1 modified"` |
| 145 | or `"working tree clean"`). |
| 146 | - `result.delta` is a valid `StateDelta`. |
| 147 | - This method MUST NOT modify any state. |
| 148 | |
| 149 | **Called by:** `muse status` |
| 150 | |
| 151 | --- |
| 152 | |
| 153 | ### 3.5 `apply(delta: StateDelta, live_state: LiveState) → LiveState` |
| 154 | |
| 155 | Apply a delta to produce a new live state. Serves as the domain-level |
| 156 | post-checkout hook. |
| 157 | |
| 158 | **Contract:** |
| 159 | - When `live_state` is a filesystem `Path`: the caller has already applied the |
| 160 | delta physically (removed deleted files, restored added/modified from the object |
| 161 | store). The plugin SHOULD rescan the directory and return the authoritative new |
| 162 | state as a `StateSnapshot`. |
| 163 | - When `live_state` is a `StateSnapshot` dict: apply removals to the in-memory dict. |
| 164 | Added/modified paths SHOULD be noted as limitations — the delta does not carry |
| 165 | content hashes, so the caller must supply them through another path. |
| 166 | - The return value MUST be a valid `LiveState`. |
| 167 | |
| 168 | **Called by:** `muse checkout` |
| 169 | |
| 170 | --- |
| 171 | |
| 172 | ## 4. Snapshot Format (Normative) |
| 173 | |
| 174 | The minimum required shape for a `StateSnapshot`: |
| 175 | |
| 176 | ```json |
| 177 | { |
| 178 | "files": { |
| 179 | "path/to/file-a": "sha256-hex-64-chars", |
| 180 | "path/to/file-b": "sha256-hex-64-chars" |
| 181 | }, |
| 182 | "domain": "my_domain_name" |
| 183 | } |
| 184 | ``` |
| 185 | |
| 186 | Plugins MAY add additional top-level keys for domain-specific metadata: |
| 187 | |
| 188 | ```json |
| 189 | { |
| 190 | "files": { ... }, |
| 191 | "domain": "music", |
| 192 | "tempo_bpm": 120, |
| 193 | "key": "Am" |
| 194 | } |
| 195 | ``` |
| 196 | |
| 197 | Additional keys MUST be JSON-serializable. The core engine ignores them; they |
| 198 | are available to domain-specific CLI commands via `plugin.snapshot()`. |
| 199 | |
| 200 | --- |
| 201 | |
| 202 | ## 5. Naming Conventions |
| 203 | |
| 204 | | Scope | Convention | |
| 205 | |---|---| |
| 206 | | Wire format (JSON) | `camelCase` | |
| 207 | | Python internals | `snake_case` | |
| 208 | | Plugin domain name in `repo.json` | `snake_case` | |
| 209 | | Workspace-relative paths in snapshots | POSIX forward-slash separators | |
| 210 | |
| 211 | --- |
| 212 | |
| 213 | ## 6. Implementing a Plugin |
| 214 | |
| 215 | Minimum viable implementation in Python: |
| 216 | |
| 217 | ```python |
| 218 | from muse.domain import ( |
| 219 | DeltaManifest, DriftReport, LiveState, MergeResult, |
| 220 | MuseDomainPlugin, SnapshotManifest, StateDelta, StateSnapshot, |
| 221 | ) |
| 222 | |
| 223 | class MyDomainPlugin: |
| 224 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 225 | if isinstance(live_state, pathlib.Path): |
| 226 | files = { |
| 227 | f.relative_to(live_state).as_posix(): _hash(f) |
| 228 | for f in sorted(live_state.rglob("*")) |
| 229 | if f.is_file() |
| 230 | } |
| 231 | return SnapshotManifest(files=files, domain="my_domain") |
| 232 | return live_state # already a snapshot dict |
| 233 | |
| 234 | def diff(self, base: StateSnapshot, target: StateSnapshot) -> StateDelta: |
| 235 | b, t = base["files"], target["files"] |
| 236 | return DeltaManifest( |
| 237 | domain="my_domain", |
| 238 | added=sorted(set(t) - set(b)), |
| 239 | removed=sorted(set(b) - set(t)), |
| 240 | modified=sorted(p for p in set(b) & set(t) if b[p] != t[p]), |
| 241 | ) |
| 242 | |
| 243 | def merge( |
| 244 | self, |
| 245 | base: StateSnapshot, |
| 246 | left: StateSnapshot, |
| 247 | right: StateSnapshot, |
| 248 | *, |
| 249 | repo_root: pathlib.Path | None = None, |
| 250 | ) -> MergeResult: |
| 251 | # ... domain-specific reconciliation ... |
| 252 | # Load .museattributes if repo_root is provided and apply strategies. |
| 253 | |
| 254 | def drift(self, committed, live) -> DriftReport: |
| 255 | live_snap = self.snapshot(live) |
| 256 | delta = self.diff(committed, live_snap) |
| 257 | has_drift = any([delta["added"], delta["removed"], delta["modified"]]) |
| 258 | return DriftReport(has_drift=has_drift, summary="...", delta=delta) |
| 259 | |
| 260 | def apply(self, delta, live_state) -> LiveState: |
| 261 | if isinstance(live_state, pathlib.Path): |
| 262 | return self.snapshot(live_state) |
| 263 | files = dict(live_state["files"]) |
| 264 | for p in delta["removed"]: |
| 265 | files.pop(p, None) |
| 266 | return SnapshotManifest(files=files, domain="my_domain") |
| 267 | ``` |
| 268 | |
| 269 | See `muse/plugins/music/plugin.py` for the complete reference implementation. |
| 270 | |
| 271 | --- |
| 272 | |
| 273 | ## 7. Invariants the Core Engine Relies On |
| 274 | |
| 275 | The core engine assumes: |
| 276 | |
| 277 | 1. `snapshot(snapshot_dict)` returns the dict unchanged. |
| 278 | 2. `diff(s, s)` returns empty `added`, `removed`, `modified` for identical snapshots. |
| 279 | 3. `merge(base, s, s)` returns `s` with an empty `conflicts` list. |
| 280 | 4. `drift(s, path_to_workdir_matching_s)` returns `has_drift=False`. |
| 281 | 5. Object IDs in `StateSnapshot["files"]` are valid SHA-256 hex strings (64 chars). |
| 282 | |
| 283 | Violating these invariants will cause incorrect behavior in `checkout`, `status`, |
| 284 | and merge state detection. |