cgcardona / muse public
type-contracts.md markdown
1058 lines 35.4 KB
ac0b459d docs: add type-contracts reference with full entity surface and Mermaid… Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 # Muse VCS — Type Contracts Reference
2
3 > Updated: 2026-03-16 | Reflects every named entity in the Muse VCS surface:
4 > domain protocol types, store wire-format TypedDicts, in-memory dataclasses,
5 > merge/config/stash types, MIDI import types, error hierarchy, and CLI enums.
6 > `Any` and `object` do not exist in any production file. Every type boundary
7 > is named. The typing audit ratchet enforces zero violations on every CI run.
8
9 This document is the single source of truth for every named entity —
10 `TypedDict`, `dataclass`, `Protocol`, `Enum`, `TypeAlias` — in the Muse
11 codebase. It covers the full contract of each type: fields, types,
12 optionality, and intended use.
13
14 ---
15
16 ## Table of Contents
17
18 1. [Design Philosophy](#design-philosophy)
19 2. [Domain Protocol Types (`muse/domain.py`)](#domain-protocol-types)
20 - [Snapshot and Delta TypedDicts](#snapshot-and-delta-typeddicts)
21 - [Type Aliases](#type-aliases)
22 - [MergeResult and DriftReport Dataclasses](#mergeresult-and-driftreport-dataclasses)
23 - [MuseDomainPlugin Protocol](#musedomainplugin-protocol)
24 3. [Store Types (`muse/core/store.py`)](#store-types)
25 - [Wire-Format TypedDicts](#wire-format-typeddicts)
26 - [In-Memory Dataclasses](#in-memory-dataclasses)
27 4. [Merge Engine Types (`muse/core/merge_engine.py`)](#merge-engine-types)
28 5. [Configuration Types (`muse/cli/config.py`)](#configuration-types)
29 6. [MIDI / MusicXML Import Types (`muse/cli/midi_parser.py`)](#midi--musicxml-import-types)
30 7. [Stash Types (`muse/cli/commands/stash.py`)](#stash-types)
31 8. [Error Hierarchy (`muse/core/errors.py`)](#error-hierarchy)
32 9. [Entity Hierarchy](#entity-hierarchy)
33 10. [Entity Graphs (Mermaid)](#entity-graphs-mermaid)
34
35 ---
36
37 ## Design Philosophy
38
39 Every entity in this codebase follows five rules:
40
41 1. **No `Any`. No `object`. Ever.** Both collapse type safety for downstream
42 callers. Every boundary is typed with a concrete named entity — `TypedDict`,
43 `dataclass`, `Protocol`, or a specific union. The CI typing audit enforces
44 this with a ratchet of zero violations.
45
46 2. **No covariance in collection aliases.** `dict[str, str]` and
47 `list[str]` are used directly. If a function's return mixes value types,
48 create a `TypedDict` for that shape instead of using `dict[str, str | int]`.
49
50 3. **Boundaries own coercion.** When external data arrives (JSON from disk,
51 TOML from config, MIDI bytes from disk), the boundary module coerces it
52 to the canonical internal type using `isinstance` narrowing. Downstream
53 code always sees clean types.
54
55 4. **Wire-format TypedDicts for serialisation, dataclasses for in-memory
56 logic.** `CommitDict`, `SnapshotDict`, `TagDict` are JSON-serialisable
57 and used by `to_dict()` / `from_dict()`. `CommitRecord`, `SnapshotRecord`,
58 `TagRecord` are rich dataclasses with typed `datetime` fields used in
59 business logic.
60
61 5. **The plugin protocol is the extension point.** All domain-specific logic
62 lives behind `MuseDomainPlugin`. The core DAG engine, branching, and
63 merge machinery know nothing about music, genomics, or any other domain.
64 Swapping domains is a one-file operation.
65
66 ### What to use instead
67
68 | Banned | Use instead |
69 |--------|-------------|
70 | `Any` | `TypedDict`, `dataclass`, specific union |
71 | `object` | The actual type or a constrained union |
72 | `list` (bare) | `list[X]` with concrete element type |
73 | `dict` (bare) | `dict[K, V]` with concrete key/value types |
74 | `dict[str, X]` with known keys | `TypedDict` — name the keys |
75 | `Optional[X]` | `X \| None` |
76 | Legacy `List`, `Dict`, `Set`, `Tuple` | Lowercase builtins |
77 | `cast(T, x)` | Fix the callee to return `T` |
78 | `# type: ignore` | Fix the underlying type error |
79
80 ---
81
82 ## Domain Protocol Types
83
84 **Path:** `muse/domain.py`
85
86 The five-interface contract that every domain plugin must satisfy. The core
87 engine implements the DAG, branching, merge-base finding, and lineage walking.
88 A domain plugin provides the five methods and gets the full VCS for free.
89
90 ### Snapshot and Delta TypedDicts
91
92 #### `SnapshotManifest`
93
94 `TypedDict` — Content-addressed snapshot of domain state. Used as the
95 canonical representation of a point-in-time capture. JSON-serialisable and
96 content-addressable via SHA-256.
97
98 | Field | Type | Description |
99 |-------|------|-------------|
100 | `files` | `dict[str, str]` | Workspace-relative POSIX paths → SHA-256 content digests |
101 | `domain` | `str` | Plugin identifier that produced this snapshot (e.g. `"music"`) |
102
103 **Example:**
104 ```json
105 {
106 "files": {
107 "tracks/drums.mid": "a3f8...",
108 "tracks/bass.mid": "b291..."
109 },
110 "domain": "music"
111 }
112 ```
113
114 #### `DeltaManifest`
115
116 `TypedDict` — Minimal change description between two snapshots. Each list
117 contains workspace-relative POSIX paths. Produced by `MuseDomainPlugin.diff()`.
118
119 | Field | Type | Description |
120 |-------|------|-------------|
121 | `domain` | `str` | Plugin identifier that produced this delta |
122 | `added` | `list[str]` | Paths present in target but absent from base |
123 | `removed` | `list[str]` | Paths present in base but absent from target |
124 | `modified` | `list[str]` | Paths present in both with differing digests |
125
126 ### Type Aliases
127
128 | Alias | Definition | Description |
129 |-------|-----------|-------------|
130 | `LiveState` | `SnapshotManifest \| pathlib.Path` | Current domain state — either an in-memory snapshot dict or a `muse-work/` directory path |
131 | `StateSnapshot` | `SnapshotManifest` | A content-addressed, immutable capture of state at a point in time |
132 | `StateDelta` | `DeltaManifest` | The minimal change between two snapshots |
133
134 `LiveState` carries two forms intentionally: the CLI path is used when commands
135 interact with the filesystem (`muse commit`, `muse status`); the snapshot form
136 is used when the engine constructs merges and diffs entirely in memory.
137
138 ### MergeResult and DriftReport Dataclasses
139
140 #### `MergeResult`
141
142 `@dataclass` — Outcome of a three-way merge between two divergent state lines.
143 An empty `conflicts` list means the merge was clean.
144
145 | Field | Type | Default | Description |
146 |-------|------|---------|-------------|
147 | `merged` | `StateSnapshot` | required | The reconciled snapshot |
148 | `conflicts` | `list[str]` | `[]` | Human-readable conflict descriptions |
149
150 **Property:**
151
152 | Name | Returns | Description |
153 |------|---------|-------------|
154 | `is_clean` | `bool` | `True` when `conflicts` is empty |
155
156 #### `DriftReport`
157
158 `@dataclass` — Gap between committed state and current live state. Produced by
159 `MuseDomainPlugin.drift()` and consumed by `muse status`.
160
161 | Field | Type | Default | Description |
162 |-------|------|---------|-------------|
163 | `has_drift` | `bool` | required | `True` when live state differs from committed snapshot |
164 | `summary` | `str` | `""` | Human-readable description (e.g. `"2 added, 1 modified"`) |
165 | `delta` | `StateDelta` | empty `DeltaManifest` | Machine-readable diff for programmatic consumers |
166
167 ### MuseDomainPlugin Protocol
168
169 `@runtime_checkable Protocol` — The five interfaces a domain plugin must
170 implement. Runtime-checkable so that `assert isinstance(plugin, MuseDomainPlugin)`
171 works as a module-load sanity check.
172
173 | Method | Signature | Description |
174 |--------|-----------|-------------|
175 | `snapshot` | `(live_state: LiveState) -> StateSnapshot` | Capture current state as a content-addressed dict |
176 | `diff` | `(base: StateSnapshot, target: StateSnapshot) -> StateDelta` | Compute the minimal delta between two snapshots |
177 | `merge` | `(base, left, right: StateSnapshot) -> MergeResult` | Three-way merge two divergent state lines |
178 | `drift` | `(committed: StateSnapshot, live: LiveState) -> DriftReport` | Compare committed state vs current live state |
179 | `apply` | `(delta: StateDelta, live_state: LiveState) -> LiveState` | Apply a delta to produce a new live state |
180
181 The music plugin (`muse.plugins.music.plugin`) is the reference implementation.
182 Every other domain — scientific simulation, genomics, 3D spatial design,
183 spacetime — implements these five methods and registers itself as a plugin.
184
185 ---
186
187 ## Store Types
188
189 **Path:** `muse/core/store.py`
190
191 All commit and snapshot metadata is stored as JSON files under `.muse/`.
192 Wire-format `TypedDict`s are the JSON-serialisable shapes used in `to_dict()`
193 and `from_dict()`. In-memory `dataclass`es are the rich representations used
194 in business logic throughout the CLI commands.
195
196 ### Wire-Format TypedDicts
197
198 These types appear at the boundary between Python objects and JSON on disk.
199 `json.loads()` returns an untyped result; `from_dict()` methods consume it and
200 return typed dataclasses. `to_dict()` methods produce these TypedDicts for
201 `json.dumps()`.
202
203 #### `CommitDict`
204
205 `TypedDict` — JSON-serialisable representation of a commit record. All datetime
206 values are ISO-8601 strings; callers convert to `datetime` inside `from_dict()`.
207
208 | Field | Type | Description |
209 |-------|------|-------------|
210 | `commit_id` | `str` | SHA-256 hex digest of the commit's canonical inputs |
211 | `repo_id` | `str` | UUID identifying the repository |
212 | `branch` | `str` | Branch name at time of commit |
213 | `snapshot_id` | `str` | SHA-256 hex digest of the attached snapshot |
214 | `message` | `str` | Commit message |
215 | `committed_at` | `str` | ISO-8601 UTC timestamp |
216 | `parent_commit_id` | `str \| None` | First parent commit ID; `None` for initial commit |
217 | `parent2_commit_id` | `str \| None` | Second parent commit ID; non-`None` only for merge commits |
218 | `author` | `str` | Author name string |
219 | `metadata` | `dict[str, str]` | Extensible string→string metadata bag |
220
221 #### `SnapshotDict`
222
223 `TypedDict` — JSON-serialisable representation of a snapshot record.
224
225 | Field | Type | Description |
226 |-------|------|-------------|
227 | `snapshot_id` | `str` | SHA-256 hex digest of the manifest |
228 | `manifest` | `dict[str, str]` | POSIX path → SHA-256 object digest |
229 | `created_at` | `str` | ISO-8601 UTC timestamp |
230
231 #### `TagDict`
232
233 `TypedDict` — JSON-serialisable representation of a semantic tag.
234
235 | Field | Type | Description |
236 |-------|------|-------------|
237 | `tag_id` | `str` | UUID identifying the tag |
238 | `repo_id` | `str` | UUID identifying the repository |
239 | `commit_id` | `str` | SHA-256 commit ID this tag points to |
240 | `tag` | `str` | Tag name string (e.g. `"v1.0"`) |
241 | `created_at` | `str` | ISO-8601 UTC timestamp |
242
243 #### `RemoteCommitPayload`
244
245 `TypedDict (total=False)` — Wire format received from a remote during push/pull.
246 All fields are optional because the remote payload may omit fields unknown to
247 older protocol versions. Callers validate required fields before constructing
248 a `CommitRecord`.
249
250 | Field | Type | Description |
251 |-------|------|-------------|
252 | `commit_id` | `str` | Commit identifier |
253 | `repo_id` | `str` | Repository UUID |
254 | `branch` | `str` | Branch name |
255 | `snapshot_id` | `str` | Snapshot identifier |
256 | `message` | `str` | Commit message |
257 | `committed_at` | `str` | ISO-8601 timestamp |
258 | `parent_commit_id` | `str \| None` | First parent |
259 | `parent2_commit_id` | `str \| None` | Second parent (merge commits) |
260 | `author` | `str` | Author name |
261 | `metadata` | `dict[str, str]` | Metadata bag |
262 | `manifest` | `dict[str, str]` | Inline snapshot manifest (remote optimisation) |
263
264 ### In-Memory Dataclasses
265
266 These rich types are constructed from wire-format TypedDicts after loading from
267 disk. They carry typed `datetime` values and are used throughout CLI command
268 implementations.
269
270 #### `CommitRecord`
271
272 `@dataclass` — In-memory representation of a commit.
273
274 | Field | Type | Default | Description |
275 |-------|------|---------|-------------|
276 | `commit_id` | `str` | required | SHA-256 hex digest |
277 | `repo_id` | `str` | required | Repository UUID |
278 | `branch` | `str` | required | Branch name |
279 | `snapshot_id` | `str` | required | Attached snapshot digest |
280 | `message` | `str` | required | Commit message |
281 | `committed_at` | `datetime.datetime` | required | UTC commit timestamp |
282 | `parent_commit_id` | `str \| None` | `None` | First parent; `None` for root commits |
283 | `parent2_commit_id` | `str \| None` | `None` | Second parent for merge commits |
284 | `author` | `str` | `""` | Author name |
285 | `metadata` | `dict[str, str]` | `{}` | Extensible string→string metadata |
286
287 **Methods:**
288
289 | Method | Returns | Description |
290 |--------|---------|-------------|
291 | `to_dict()` | `CommitDict` | Serialise to JSON-ready TypedDict |
292 | `from_dict(d: CommitDict)` | `CommitRecord` | Deserialise from JSON-loaded TypedDict |
293
294 #### `SnapshotRecord`
295
296 `@dataclass` — In-memory representation of a content-addressed snapshot.
297
298 | Field | Type | Default | Description |
299 |-------|------|---------|-------------|
300 | `snapshot_id` | `str` | required | SHA-256 hex digest of the manifest |
301 | `manifest` | `dict[str, str]` | required | POSIX path → SHA-256 object digest |
302 | `created_at` | `datetime.datetime` | UTC now | Creation timestamp |
303
304 **Methods:** `to_dict() -> SnapshotDict`, `from_dict(d: SnapshotDict) -> SnapshotRecord`
305
306 #### `TagRecord`
307
308 `@dataclass` — In-memory representation of a semantic tag.
309
310 | Field | Type | Default | Description |
311 |-------|------|---------|-------------|
312 | `tag_id` | `str` | required | UUID |
313 | `repo_id` | `str` | required | Repository UUID |
314 | `commit_id` | `str` | required | Tagged commit's SHA-256 digest |
315 | `tag` | `str` | required | Tag name |
316 | `created_at` | `datetime.datetime` | UTC now | Creation timestamp |
317
318 **Methods:** `to_dict() -> TagDict`, `from_dict(d: TagDict) -> TagRecord`
319
320 ---
321
322 ## Merge Engine Types
323
324 **Path:** `muse/core/merge_engine.py`
325
326 #### `MergeStatePayload`
327
328 `TypedDict (total=False)` — JSON-serialisable form of an in-progress merge
329 state. Written to `.muse/MERGE_STATE.json` when a merge has unresolved
330 conflicts. All fields are optional in the TypedDict because `other_branch` is
331 only set when the merge has a named second branch.
332
333 | Field | Type | Description |
334 |-------|------|-------------|
335 | `base_commit` | `str` | Common ancestor commit ID |
336 | `ours_commit` | `str` | Current branch HEAD at merge start |
337 | `theirs_commit` | `str` | Incoming branch HEAD at merge start |
338 | `conflict_paths` | `list[str]` | POSIX paths with unresolved conflicts |
339 | `other_branch` | `str` | Name of the branch being merged in (optional) |
340
341 #### `MergeState`
342
343 `@dataclass (frozen=True)` — Loaded in-memory representation of
344 `MERGE_STATE.json`. Immutable so it can be passed around without accidental
345 mutation.
346
347 | Field | Type | Default | Description |
348 |-------|------|---------|-------------|
349 | `conflict_paths` | `list[str]` | `[]` | Paths with unresolved conflicts |
350 | `base_commit` | `str \| None` | `None` | Common ancestor commit ID |
351 | `ours_commit` | `str \| None` | `None` | Our HEAD at merge start |
352 | `theirs_commit` | `str \| None` | `None` | Their HEAD at merge start |
353 | `other_branch` | `str \| None` | `None` | Name of the incoming branch |
354
355 ---
356
357 ## Configuration Types
358
359 **Path:** `muse/cli/config.py`
360
361 The structured view of `.muse/config.toml`. Loading from TOML uses `isinstance`
362 narrowing from `tomllib`'s untyped output — no `Any` annotation is ever written
363 in source. All mutation functions read the current config, modify the specific
364 section, and write back.
365
366 #### `AuthEntry`
367
368 `TypedDict (total=False)` — `[auth]` section in `.muse/config.toml`.
369
370 | Field | Type | Description |
371 |-------|------|-------------|
372 | `token` | `str` | Bearer token for Muse Hub authentication. **Never logged.** |
373
374 #### `RemoteEntry`
375
376 `TypedDict (total=False)` — `[remotes.<name>]` section in `.muse/config.toml`.
377
378 | Field | Type | Description |
379 |-------|------|-------------|
380 | `url` | `str` | Remote Hub URL (e.g. `"https://hub.example.com/repos/my-repo"`) |
381 | `branch` | `str` | Upstream branch tracked by this remote (set by `--set-upstream`) |
382
383 #### `MuseConfig`
384
385 `TypedDict (total=False)` — Structured view of the entire `.muse/config.toml`
386 file. All sections are optional; an empty dict is a valid `MuseConfig`.
387
388 | Field | Type | Description |
389 |-------|------|-------------|
390 | `auth` | `AuthEntry` | Authentication credentials section |
391 | `remotes` | `dict[str, RemoteEntry]` | Named remote sections |
392
393 #### `RemoteConfig`
394
395 `TypedDict` — Public-facing remote descriptor returned by `list_remotes()`.
396 A lightweight projection of `RemoteEntry` that always has both required fields.
397
398 | Field | Type | Description |
399 |-------|------|-------------|
400 | `name` | `str` | Remote name (e.g. `"origin"`) |
401 | `url` | `str` | Remote URL |
402
403 ---
404
405 ## MIDI / MusicXML Import Types
406
407 **Path:** `muse/cli/midi_parser.py`
408
409 Types used by `muse import` to parse Standard MIDI Files and MusicXML documents
410 into Muse's internal note representation.
411
412 #### `MidiMeta`
413
414 `TypedDict` — Format-specific metadata for Standard MIDI Files (`.mid`, `.midi`).
415
416 | Field | Type | Description |
417 |-------|------|-------------|
418 | `num_tracks` | `int` | Number of MIDI tracks in the file |
419
420 #### `MusicXMLMeta`
421
422 `TypedDict` — Format-specific metadata for MusicXML files (`.xml`, `.musicxml`).
423
424 | Field | Type | Description |
425 |-------|------|-------------|
426 | `num_parts` | `int` | Number of parts (instruments) in the score |
427 | `part_names` | `list[str]` | Display names of each part |
428
429 #### `RawMeta`
430
431 `TypeAlias = MidiMeta | MusicXMLMeta` — Discriminated union of all
432 format-specific metadata shapes. The `MuseImportData.raw_meta` field carries
433 one of these two named types depending on the source file's format.
434
435 #### `NoteEvent`
436
437 `@dataclass` — A single sounding note extracted from an imported file.
438
439 | Field | Type | Description |
440 |-------|------|-------------|
441 | `pitch` | `int` | MIDI pitch number (0–127) |
442 | `velocity` | `int` | MIDI velocity (0–127; 0 = note-off) |
443 | `start_tick` | `int` | Onset tick relative to file start |
444 | `duration_ticks` | `int` | Note length in MIDI ticks |
445 | `channel` | `int` | MIDI channel (0–15) |
446 | `channel_name` | `str` | Track/part name for this channel |
447
448 #### `MuseImportData`
449
450 `@dataclass` — All data extracted from a single imported music file. The
451 complete parsed result returned by `parse_file()`.
452
453 | Field | Type | Description |
454 |-------|------|-------------|
455 | `source_path` | `pathlib.Path` | Absolute path to the source file |
456 | `format` | `str` | `"midi"` or `"musicxml"` |
457 | `ticks_per_beat` | `int` | MIDI timing resolution (pulses per quarter note) |
458 | `tempo_bpm` | `float` | Tempo in beats per minute |
459 | `notes` | `list[NoteEvent]` | All sounding notes, in onset order |
460 | `tracks` | `list[str]` | Track/part names present in the file |
461 | `raw_meta` | `RawMeta` | Format-specific metadata (`MidiMeta` or `MusicXMLMeta`) |
462
463 ---
464
465 ## Stash Types
466
467 **Path:** `muse/cli/commands/stash.py`
468
469 #### `StashEntry`
470
471 `TypedDict` — A single entry in the stash stack, persisted to
472 `.muse/stash.json` as one element of a JSON array.
473
474 | Field | Type | Description |
475 |-------|------|-------------|
476 | `snapshot_id` | `str` | SHA-256 content digest of the stashed snapshot |
477 | `manifest` | `dict[str, str]` | POSIX path → SHA-256 object digest of stashed files |
478 | `branch` | `str` | Branch name that was active when the stash was saved |
479 | `stashed_at` | `str` | ISO-8601 UTC timestamp of the stash operation |
480
481 ---
482
483 ## Error Hierarchy
484
485 **Path:** `muse/core/errors.py`
486
487 #### `ExitCode`
488
489 `IntEnum` — Standardised CLI exit codes. Used throughout the CLI commands via
490 `raise typer.Exit(code=ExitCode.USER_ERROR)`.
491
492 | Value | Integer | Meaning |
493 |-------|---------|---------|
494 | `SUCCESS` | `0` | Command completed successfully |
495 | `USER_ERROR` | `1` | Bad arguments or invalid user input |
496 | `REPO_NOT_FOUND` | `2` | Not inside a Muse repository |
497 | `INTERNAL_ERROR` | `3` | Unexpected internal failure |
498
499 #### `MuseCLIError`
500
501 `Exception` — Base exception for all Muse CLI errors. Carries an exit code
502 so that top-level handlers can produce the correct process exit.
503
504 | Field | Type | Description |
505 |-------|------|-------------|
506 | `exit_code` | `ExitCode` | Exit code to use when this exception terminates the process |
507
508 #### `RepoNotFoundError`
509
510 `MuseCLIError` — Raised by `find_repo_root()` callers when a command is invoked
511 outside a Muse repository. Default message: `"Not a Muse repository. Run muse init."` Default exit code: `ExitCode.REPO_NOT_FOUND`.
512
513 **Alias:** `MuseNotARepoError = RepoNotFoundError`
514
515 ---
516
517 ## Entity Hierarchy
518
519 ```
520 Muse VCS
521
522 ├── Domain Protocol (muse/domain.py)
523 │ │
524 │ ├── Snapshot and Delta
525 │ │ ├── SnapshotManifest — TypedDict: {files: dict[str,str], domain: str}
526 │ │ └── DeltaManifest — TypedDict: {domain, added, removed, modified}
527 │ │
528 │ ├── Type Aliases
529 │ │ ├── LiveState — SnapshotManifest | pathlib.Path
530 │ │ ├── StateSnapshot — SnapshotManifest
531 │ │ └── StateDelta — DeltaManifest
532 │ │
533 │ ├── Result Types
534 │ │ ├── MergeResult — dataclass: merged + conflicts list
535 │ │ └── DriftReport — dataclass: has_drift + summary + delta
536 │ │
537 │ └── MuseDomainPlugin — Protocol (runtime_checkable): 5 methods
538
539 ├── Store (muse/core/store.py)
540 │ │
541 │ ├── Wire-Format TypedDicts
542 │ │ ├── CommitDict — TypedDict: all commit fields (str timestamps)
543 │ │ ├── SnapshotDict — TypedDict: snapshot_id + manifest + created_at
544 │ │ ├── TagDict — TypedDict: tag identity fields
545 │ │ └── RemoteCommitPayload — TypedDict (total=False): wire format + manifest
546 │ │
547 │ └── In-Memory Dataclasses
548 │ ├── CommitRecord — dataclass: typed datetime, to_dict/from_dict
549 │ ├── SnapshotRecord — dataclass: manifest + datetime
550 │ └── TagRecord — dataclass: tag metadata + datetime
551
552 ├── Merge Engine (muse/core/merge_engine.py)
553 │ ├── MergeStatePayload — TypedDict (total=False): MERGE_STATE.json shape
554 │ └── MergeState — dataclass (frozen): loaded in-memory merge state
555
556 ├── Configuration (muse/cli/config.py)
557 │ ├── AuthEntry — TypedDict (total=False): [auth] section
558 │ ├── RemoteEntry — TypedDict (total=False): [remotes.<name>] section
559 │ ├── MuseConfig — TypedDict (total=False): full config.toml shape
560 │ └── RemoteConfig — TypedDict: public remote descriptor
561
562 ├── MIDI / MusicXML Import (muse/cli/midi_parser.py)
563 │ ├── MidiMeta — TypedDict: num_tracks
564 │ ├── MusicXMLMeta — TypedDict: num_parts + part_names
565 │ ├── RawMeta — TypeAlias: MidiMeta | MusicXMLMeta
566 │ ├── NoteEvent — dataclass: pitch, velocity, timing, channel
567 │ └── MuseImportData — dataclass: full parsed file result
568
569 ├── Stash (muse/cli/commands/stash.py)
570 │ └── StashEntry — TypedDict: snapshot_id + manifest + branch + stashed_at
571
572 └── Errors (muse/core/errors.py)
573 ├── ExitCode — IntEnum: SUCCESS=0 USER_ERROR=1 REPO_NOT_FOUND=2 INTERNAL_ERROR=3
574 ├── MuseCLIError — Exception base: carries ExitCode
575 ├── RepoNotFoundError — MuseCLIError: default exit REPO_NOT_FOUND
576 └── MuseNotARepoError — alias for RepoNotFoundError
577 ```
578
579 ---
580
581 ## Entity Graphs (Mermaid)
582
583 Arrow conventions:
584 - `*--` composition (owns, lifecycle-coupled)
585 - `-->` association (references)
586 - `..>` dependency (uses)
587 - `..>` with label: produces / implements
588
589 ---
590
591 ### Diagram 1 — Domain Protocol and Plugin Contract
592
593 The `MuseDomainPlugin` protocol and the types that flow through its five methods. `MusicPlugin` is the reference implementation that proves the abstraction.
594
595 ```mermaid
596 classDiagram
597 class SnapshotManifest {
598 <<TypedDict>>
599 +files : dict~str, str~
600 +domain : str
601 }
602 class DeltaManifest {
603 <<TypedDict>>
604 +domain : str
605 +added : list~str~
606 +removed : list~str~
607 +modified : list~str~
608 }
609 class MergeResult {
610 <<dataclass>>
611 +merged : StateSnapshot
612 +conflicts : list~str~
613 +is_clean : bool
614 }
615 class DriftReport {
616 <<dataclass>>
617 +has_drift : bool
618 +summary : str
619 +delta : StateDelta
620 }
621 class MuseDomainPlugin {
622 <<Protocol runtime_checkable>>
623 +snapshot(live_state: LiveState) StateSnapshot
624 +diff(base, target: StateSnapshot) StateDelta
625 +merge(base, left, right: StateSnapshot) MergeResult
626 +drift(committed: StateSnapshot, live: LiveState) DriftReport
627 +apply(delta: StateDelta, live_state: LiveState) LiveState
628 }
629 class MusicPlugin {
630 <<reference implementation>>
631 +snapshot(live_state) StateSnapshot
632 +diff(base, target) StateDelta
633 +merge(base, left, right) MergeResult
634 +drift(committed, live) DriftReport
635 +apply(delta, live_state) LiveState
636 }
637
638 MuseDomainPlugin ..> SnapshotManifest : StateSnapshot alias
639 MuseDomainPlugin ..> DeltaManifest : StateDelta alias
640 MuseDomainPlugin --> MergeResult : merge() returns
641 MuseDomainPlugin --> DriftReport : drift() returns
642 MusicPlugin ..|> MuseDomainPlugin : implements
643 MergeResult --> SnapshotManifest : merged
644 DriftReport --> DeltaManifest : delta
645 ```
646
647 ---
648
649 ### Diagram 2 — Store Wire-Format TypedDicts and Dataclasses
650
651 The two-layer design: wire-format TypedDicts for JSON serialisation, rich
652 dataclasses for in-memory logic. Every `from_dict` consumes the TypedDict
653 shape produced by `json.loads()`; every `to_dict` produces it for
654 `json.dumps()`.
655
656 ```mermaid
657 classDiagram
658 class CommitDict {
659 <<TypedDict wire format>>
660 +commit_id : str
661 +repo_id : str
662 +branch : str
663 +snapshot_id : str
664 +message : str
665 +committed_at : str
666 +parent_commit_id : str | None
667 +parent2_commit_id : str | None
668 +author : str
669 +metadata : dict~str, str~
670 }
671 class SnapshotDict {
672 <<TypedDict wire format>>
673 +snapshot_id : str
674 +manifest : dict~str, str~
675 +created_at : str
676 }
677 class TagDict {
678 <<TypedDict wire format>>
679 +tag_id : str
680 +repo_id : str
681 +commit_id : str
682 +tag : str
683 +created_at : str
684 }
685 class RemoteCommitPayload {
686 <<TypedDict total=False>>
687 +commit_id : str
688 +repo_id : str
689 +branch : str
690 +snapshot_id : str
691 +message : str
692 +committed_at : str
693 +parent_commit_id : str | None
694 +parent2_commit_id : str | None
695 +author : str
696 +metadata : dict~str, str~
697 +manifest : dict~str, str~
698 }
699 class CommitRecord {
700 <<dataclass>>
701 +commit_id : str
702 +repo_id : str
703 +branch : str
704 +snapshot_id : str
705 +message : str
706 +committed_at : datetime
707 +parent_commit_id : str | None
708 +parent2_commit_id : str | None
709 +author : str
710 +metadata : dict~str, str~
711 +to_dict() CommitDict
712 +from_dict(d: CommitDict) CommitRecord
713 }
714 class SnapshotRecord {
715 <<dataclass>>
716 +snapshot_id : str
717 +manifest : dict~str, str~
718 +created_at : datetime
719 +to_dict() SnapshotDict
720 +from_dict(d: SnapshotDict) SnapshotRecord
721 }
722 class TagRecord {
723 <<dataclass>>
724 +tag_id : str
725 +repo_id : str
726 +commit_id : str
727 +tag : str
728 +created_at : datetime
729 +to_dict() TagDict
730 +from_dict(d: TagDict) TagRecord
731 }
732
733 CommitRecord ..> CommitDict : to_dict produces
734 CommitDict ..> CommitRecord : from_dict produces
735 SnapshotRecord ..> SnapshotDict : to_dict produces
736 SnapshotDict ..> SnapshotRecord : from_dict produces
737 TagRecord ..> TagDict : to_dict produces
738 TagDict ..> TagRecord : from_dict produces
739 RemoteCommitPayload ..> CommitDict : store_pulled_commit builds
740 CommitRecord --> SnapshotRecord : snapshot_id reference
741 CommitRecord --> TagRecord : commit_id reference (via TagRecord)
742 ```
743
744 ---
745
746 ### Diagram 3 — Merge Engine State
747
748 The in-progress merge state written to disk on conflict and loaded on
749 continuation. `MergeStatePayload` is the JSON shape; `MergeState` is the
750 loaded, immutable in-memory form.
751
752 ```mermaid
753 classDiagram
754 class MergeStatePayload {
755 <<TypedDict total=False>>
756 +base_commit : str
757 +ours_commit : str
758 +theirs_commit : str
759 +conflict_paths : list~str~
760 +other_branch : str
761 }
762 class MergeState {
763 <<dataclass frozen>>
764 +conflict_paths : list~str~
765 +base_commit : str | None
766 +ours_commit : str | None
767 +theirs_commit : str | None
768 +other_branch : str | None
769 }
770 class CommitRecord {
771 <<dataclass>>
772 +commit_id : str
773 +branch : str
774 +parent_commit_id : str | None
775 }
776
777 MergeStatePayload ..> MergeState : read_merge_state() produces
778 MergeState ..> MergeStatePayload : write_merge_state() serialises
779 MergeState --> CommitRecord : base_commit, ours_commit, theirs_commit
780 ```
781
782 ---
783
784 ### Diagram 4 — Configuration Type Hierarchy
785
786 The structured config.toml types. `MuseConfig` is the root; mutation functions
787 read, modify a specific section, and write back. `isinstance` narrowing converts
788 `tomllib`'s untyped output to the typed structure at the load boundary.
789
790 ```mermaid
791 classDiagram
792 class MuseConfig {
793 <<TypedDict total=False>>
794 +auth : AuthEntry
795 +remotes : dict~str, RemoteEntry~
796 }
797 class AuthEntry {
798 <<TypedDict total=False>>
799 +token : str
800 }
801 class RemoteEntry {
802 <<TypedDict total=False>>
803 +url : str
804 +branch : str
805 }
806 class RemoteConfig {
807 <<TypedDict public API>>
808 +name : str
809 +url : str
810 }
811
812 MuseConfig *-- AuthEntry : auth
813 MuseConfig *-- RemoteEntry : remotes (by name)
814 RemoteEntry ..> RemoteConfig : list_remotes() projects to
815 ```
816
817 ---
818
819 ### Diagram 5 — MIDI / MusicXML Import Types
820
821 The parser output types for `muse import`. `RawMeta` is a discriminated union
822 of two named shapes; no dict with mixed value types is exposed.
823
824 ```mermaid
825 classDiagram
826 class MidiMeta {
827 <<TypedDict>>
828 +num_tracks : int
829 }
830 class MusicXMLMeta {
831 <<TypedDict>>
832 +num_parts : int
833 +part_names : list~str~
834 }
835 class NoteEvent {
836 <<dataclass>>
837 +pitch : int
838 +velocity : int
839 +start_tick : int
840 +duration_ticks : int
841 +channel : int
842 +channel_name : str
843 }
844 class MuseImportData {
845 <<dataclass>>
846 +source_path : Path
847 +format : str
848 +ticks_per_beat : int
849 +tempo_bpm : float
850 +notes : list~NoteEvent~
851 +tracks : list~str~
852 +raw_meta : RawMeta
853 }
854 class RawMeta {
855 <<TypeAlias>>
856 MidiMeta | MusicXMLMeta
857 }
858
859 MuseImportData *-- NoteEvent : notes
860 MuseImportData --> RawMeta : raw_meta
861 RawMeta ..> MidiMeta : MIDI files
862 RawMeta ..> MusicXMLMeta : MusicXML files
863 ```
864
865 ---
866
867 ### Diagram 6 — Error Hierarchy
868
869 Exit codes, base exception, and concrete error types. Every CLI command raises
870 a typed exception or calls `raise typer.Exit(code=ExitCode.X)`.
871
872 ```mermaid
873 classDiagram
874 class ExitCode {
875 <<IntEnum>>
876 SUCCESS = 0
877 USER_ERROR = 1
878 REPO_NOT_FOUND = 2
879 INTERNAL_ERROR = 3
880 }
881 class MuseCLIError {
882 <<Exception>>
883 +exit_code : ExitCode
884 }
885 class RepoNotFoundError {
886 <<MuseCLIError>>
887 default exit_code = REPO_NOT_FOUND
888 default message = Not a Muse repository
889 }
890
891 MuseCLIError --> ExitCode : exit_code
892 RepoNotFoundError --|> MuseCLIError
893 ```
894
895 ---
896
897 ### Diagram 7 — Stash Stack
898
899 The stash is a JSON array of `StashEntry` TypedDicts persisted to
900 `.muse/stash.json`. Push prepends; pop removes index 0.
901
902 ```mermaid
903 classDiagram
904 class StashEntry {
905 <<TypedDict>>
906 +snapshot_id : str
907 +manifest : dict~str, str~
908 +branch : str
909 +stashed_at : str
910 }
911 class SnapshotRecord {
912 <<dataclass>>
913 +snapshot_id : str
914 +manifest : dict~str, str~
915 }
916
917 StashEntry ..> SnapshotRecord : snapshot_id + manifest mirror
918 ```
919
920 ---
921
922 ### Diagram 8 — Full Entity Overview
923
924 All named entities grouped by layer, showing the dependency flow from the
925 domain protocol down through the store, CLI, and plugin layers.
926
927 ```mermaid
928 classDiagram
929 class MuseDomainPlugin {
930 <<Protocol>>
931 snapshot / diff / merge / drift / apply
932 }
933 class SnapshotManifest {
934 <<TypedDict>>
935 files: dict~str,str~ · domain: str
936 }
937 class DeltaManifest {
938 <<TypedDict>>
939 domain · added · removed · modified
940 }
941 class MergeResult {
942 <<dataclass>>
943 merged: StateSnapshot · conflicts: list~str~
944 }
945 class DriftReport {
946 <<dataclass>>
947 has_drift · summary · delta
948 }
949 class CommitRecord {
950 <<dataclass>>
951 commit_id · branch · snapshot_id · metadata
952 }
953 class SnapshotRecord {
954 <<dataclass>>
955 snapshot_id · manifest: dict~str,str~
956 }
957 class TagRecord {
958 <<dataclass>>
959 tag_id · commit_id · tag
960 }
961 class CommitDict {
962 <<TypedDict wire>>
963 all str fields · committed_at: str
964 }
965 class SnapshotDict {
966 <<TypedDict wire>>
967 snapshot_id · manifest · created_at
968 }
969 class TagDict {
970 <<TypedDict wire>>
971 tag_id · repo_id · commit_id · tag
972 }
973 class RemoteCommitPayload {
974 <<TypedDict total=False>>
975 wire format + manifest
976 }
977 class MergeState {
978 <<dataclass frozen>>
979 conflict_paths · base/ours/theirs commits
980 }
981 class MergeStatePayload {
982 <<TypedDict total=False>>
983 MERGE_STATE.json shape
984 }
985 class MuseConfig {
986 <<TypedDict total=False>>
987 auth · remotes
988 }
989 class AuthEntry {
990 <<TypedDict total=False>>
991 token: str
992 }
993 class RemoteEntry {
994 <<TypedDict total=False>>
995 url · branch
996 }
997 class RemoteConfig {
998 <<TypedDict>>
999 name · url
1000 }
1001 class StashEntry {
1002 <<TypedDict>>
1003 snapshot_id · manifest · branch · stashed_at
1004 }
1005 class MuseImportData {
1006 <<dataclass>>
1007 notes · tracks · raw_meta
1008 }
1009 class NoteEvent {
1010 <<dataclass>>
1011 pitch · velocity · timing · channel
1012 }
1013 class MidiMeta {
1014 <<TypedDict>>
1015 num_tracks: int
1016 }
1017 class MusicXMLMeta {
1018 <<TypedDict>>
1019 num_parts · part_names
1020 }
1021 class ExitCode {
1022 <<IntEnum>>
1023 SUCCESS=0 · USER_ERROR=1 · REPO_NOT_FOUND=2 · INTERNAL_ERROR=3
1024 }
1025 class MuseCLIError {
1026 <<Exception>>
1027 exit_code: ExitCode
1028 }
1029 class RepoNotFoundError {
1030 <<MuseCLIError>>
1031 }
1032
1033 MuseDomainPlugin ..> SnapshotManifest : StateSnapshot
1034 MuseDomainPlugin ..> DeltaManifest : StateDelta
1035 MuseDomainPlugin --> MergeResult : merge() returns
1036 MuseDomainPlugin --> DriftReport : drift() returns
1037 MergeResult --> SnapshotManifest : merged
1038 DriftReport --> DeltaManifest : delta
1039
1040 CommitRecord ..> CommitDict : to_dict / from_dict
1041 SnapshotRecord ..> SnapshotDict : to_dict / from_dict
1042 TagRecord ..> TagDict : to_dict / from_dict
1043 RemoteCommitPayload ..> CommitDict : store_pulled_commit
1044 CommitRecord --> SnapshotRecord : snapshot_id
1045 MergeState ..> MergeStatePayload : serialise / deserialise
1046
1047 MuseConfig *-- AuthEntry : auth
1048 MuseConfig *-- RemoteEntry : remotes
1049 RemoteEntry ..> RemoteConfig : list_remotes()
1050
1051 StashEntry ..> SnapshotRecord : snapshot_id
1052 MuseImportData *-- NoteEvent : notes
1053 MuseImportData --> MidiMeta : raw_meta (MIDI)
1054 MuseImportData --> MusicXMLMeta : raw_meta (XML)
1055
1056 RepoNotFoundError --|> MuseCLIError
1057 MuseCLIError --> ExitCode : exit_code
1058 ```