cgcardona / muse public
muse-protocol.md markdown
257 lines 8.9 KB
83fa3d6e docs: full sweep — domain-agnostic rewrite of all docs Gabriel Cardona <gabriel@tellurstori.com> 3d ago
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.