gabriel / muse public
patch_record.py python
376 lines 12.8 KB
Raw
sha256:94f494a8f59e4b708ebb89e304209737ce34324a33aae83840a6f6b06d7b8d9d docs: add domain-extensibility.md — the two-axis breadth/de… Sonnet 5 2 days ago
1 """patch_record.py — Content-addressed Muse patch objects.
2
3 A Muse patch (``.mpatch``) is a content-addressed, domain-aware, agent-first
4 exchange format for communicating a single commit's state change between
5 repositories or agents.
6
7 Unlike a traditional unified diff, a Muse patch:
8
9 - Is **content-addressed**: the ``patch_id`` is ``sha256(canonical JSON)`` so
10 any tampering is immediately detectable.
11 - Carries **Cohen action labels**: every op includes an ``action_label`` field
12 (``"inserted"``, ``"deleted"``, ``"modified"``, ``"moved"``, ``"renamed"``)
13 derived from the Cohen Transform, giving agents immediate semantic context
14 without parsing the diff.
15 - Embeds **agent provenance**: ``agent_id``, ``model_id``, ``signer_public_key``,
16 and ``signature`` so every patch is traceable back to its creator.
17 - Declares **applicability**: the ``requires_snapshot`` field (the snapshot the
18 repo must be at before applying), ``independent_dimensions`` (dimensions
19 unaffected by this patch), and ``conflict_free`` (no detected overlap with
20 concurrent changes).
21 - Is **domain-tagged**: the ``domain`` field lets apply-time validation verify
22 the plugin is compatible before touching the working tree.
23 - Contains **manifest deltas**: ``from_manifest`` and ``to_manifest`` carry only
24 the changed paths (not the full repo manifest), keeping patches lean.
25 - Lists **required objects**: the ``required_objects`` field names every object
26 ID in ``to_manifest`` that the target repo must have (or fetch) before the
27 patch can be applied.
28
29 Serialization
30 -------------
31 Patches are stored as UTF-8 JSON (not msgpack) for portability and human
32 inspectability. The canonical JSON used for ``patch_id`` computation sorts all
33 keys, strips ``patch_id`` and signature-related fields (which sign the id, not
34 the other way round), and uses no separators (no whitespace).
35
36 Public API
37 ----------
38 ``PatchRecord``
39 Dataclass holding all patch fields.
40
41 ``compute_patch_id(record) -> str``
42 Compute ``sha256:<hex>`` from the record's canonical JSON. Call this with
43 ``patch_id=""`` before embedding the id in the record.
44
45 ``serialize_patch(record) -> bytes``
46 Serialize to compact UTF-8 JSON bytes.
47
48 ``deserialize_patch(data) -> PatchRecord``
49 Deserialize from bytes. Raises ``ValueError`` if the JSON is missing
50 required fields.
51 """
52
53 import json
54 from collections.abc import Mapping
55 from dataclasses import dataclass, field
56 from typing import TypedDict
57
58 from muse.core.types import content_hash, JsonValue
59
60 class PatchOp(TypedDict, total=False):
61 """A single domain operation inside a PatchRecord, with Cohen action label."""
62
63 op: str
64 address: str
65 position: int | None
66 content_id: str
67 content_summary: str
68 action_label: str
69
70 class PatchApplicability(TypedDict, total=False):
71 """Applicability metadata for a PatchRecord."""
72
73 requires_snapshot: str
74 independent_dimensions: list[str]
75 conflict_free: bool
76
77 class PatchRecordDict(TypedDict):
78 """Serialisable dict representation of a PatchRecord."""
79
80 patch_id: str
81 from_snapshot_id: str
82 to_snapshot_id: str
83 from_commit_id: str
84 to_commit_id: str
85 domain: str
86 format_version: str
87 created_at: str
88 agent_id: str
89 model_id: str
90 signer_public_key: str
91 signature: str
92 intent: str
93 sem_ver_bump: str
94 breaking_changes: list[str]
95 summary: str
96 ops: list[PatchOp]
97 files_added: list[str]
98 files_modified: list[str]
99 files_deleted: list[str]
100 files_renamed: dict[str, str]
101 required_objects: list[str]
102 from_manifest: dict[str, str]
103 to_manifest: dict[str, str]
104 applicability: PatchApplicability
105 blobs: dict[str, str]
106
107 # ---------------------------------------------------------------------------
108 # Dataclass
109 # ---------------------------------------------------------------------------
110
111 @dataclass
112 class PatchRecord:
113 """A content-addressed Muse patch record.
114
115 Fields
116 ------
117 patch_id
118 ``sha256:<64-hex>`` identity of this patch. Computed from the
119 canonical JSON of the record with ``patch_id=""``,
120 ``signature=""``, ``signer_public_key=""`` stripped.
121
122 from_snapshot_id
123 ``sha256:<64-hex>`` of the snapshot the patch departs from (the
124 parent commit's snapshot, or an empty-repo sentinel for initial
125 commits). This is also ``applicability["requires_snapshot"]``.
126
127 to_snapshot_id
128 ``sha256:<64-hex>`` of the snapshot this patch produces.
129
130 from_commit_id
131 ``sha256:<64-hex>`` of the parent commit. Empty string for the
132 initial commit.
133
134 to_commit_id
135 ``sha256:<64-hex>`` of the commit this patch was generated from.
136
137 domain
138 Domain tag (``"code"``, ``"midi"``, …).
139
140 format_version
141 Schema version string, currently ``"1.0"``.
142
143 created_at
144 ISO-8601 timestamp when the patch was created.
145
146 agent_id
147 Agent type that produced this patch (``"claude-code"``, …).
148
149 model_id
150 Model version (``"claude-sonnet-4-6"``, …).
151
152 signer_public_key
153 Ed25519 public key (hex) of the signer. Empty when unsigned.
154
155 signature
156 Ed25519 signature of ``patch_id`` (hex). Empty when unsigned.
157
158 intent
159 Human-readable description of what this patch accomplishes.
160
161 sem_ver_bump
162 Semantic version impact: ``"major"``, ``"minor"``, or ``"patch"``.
163
164 breaking_changes
165 List of human-readable breaking change descriptions.
166
167 summary
168 Short human-readable summary (e.g. ``"2 modified files"``).
169
170 ops
171 List of DomainOp-compatible dicts, each enriched with an
172 ``action_label`` field (Cohen extension).
173
174 files_added
175 Relative paths of files added by this patch.
176
177 files_modified
178 Relative paths of files modified by this patch.
179
180 files_deleted
181 Relative paths of files deleted by this patch.
182
183 files_renamed
184 Mapping of ``{old_path: new_path}`` for renames.
185
186 required_objects
187 ``sha256:`` IDs of all objects in ``to_manifest`` that an applying
188 repo must possess.
189
190 from_manifest
191 Delta manifest for the "before" state — contains only paths that
192 changed (deleted or modified).
193
194 to_manifest
195 Delta manifest for the "after" state — contains only paths that
196 changed (added or modified).
197
198 applicability
199 Dict with:
200 - ``requires_snapshot``: snapshot the repo must be at before applying
201 - ``independent_dimensions``: dimensions not touched by this patch
202 - ``conflict_free``: True if no overlap with concurrent changes was
203 detected
204 """
205
206 patch_id: str
207 from_snapshot_id: str
208 to_snapshot_id: str
209 from_commit_id: str
210 to_commit_id: str
211 domain: str
212 format_version: str
213 created_at: str
214 agent_id: str
215 model_id: str
216 signer_public_key: str
217 signature: str
218 intent: str
219 sem_ver_bump: str
220 breaking_changes: list[str]
221 summary: str
222 ops: list[PatchOp]
223 files_added: list[str]
224 files_modified: list[str]
225 files_deleted: list[str]
226 files_renamed: dict[str, str]
227 required_objects: list[str]
228 from_manifest: dict[str, str]
229 to_manifest: dict[str, str]
230 applicability: PatchApplicability
231 blobs: dict[str, str] = field(default_factory=dict)
232 """object_id → base64-encoded content.
233
234 Carries the raw bytes of every object listed in ``required_objects``
235 (base64-encoded for JSON safety). This makes a ``.mpatch`` file
236 self-contained — the applying repo does not need to pre-fetch objects
237 via a separate transfer (mpack, push/pull).
238
239 Writers (``muse format-patch``) populate this field.
240 Readers (``muse apply-patch``) write these blobs into the local object
241 store before calling ``restore_object``.
242 """
243
244 # ---------------------------------------------------------------------------
245 # patch_id computation
246 # ---------------------------------------------------------------------------
247
248 # Fields excluded from the canonical JSON used to compute patch_id.
249 # The signature signs the patch_id, not the other way round, so these
250 # must not influence the hash.
251 _EXCLUDED_FROM_ID = frozenset({"patch_id", "signature", "signer_public_key"})
252
253 def compute_patch_id(record: PatchRecord) -> str:
254 """Return ``sha256:<64-hex>`` for *record*.
255
256 The canonical form is compact JSON (no whitespace) with all keys sorted,
257 ``patch_id``, ``signature``, and ``signer_public_key`` set to ``""``.
258
259 Args:
260 record: A :class:`PatchRecord` whose ``patch_id`` field may be ``""``
261 (it is always ignored during computation).
262
263 Returns:
264 ``sha256:<64 hex>`` string.
265 """
266 raw = _record_to_dict(record)
267 canonical: dict[str, JsonValue] = {}
268 for k, v in raw.items():
269 if k in _EXCLUDED_FROM_ID:
270 canonical[k] = ""
271 else:
272 canonical[k] = v # type: ignore[assignment]
273 return content_hash(canonical)
274
275 # ---------------------------------------------------------------------------
276 # Serialization
277 # ---------------------------------------------------------------------------
278
279 def serialize_patch(record: PatchRecord) -> bytes:
280 """Serialize *record* to compact UTF-8 JSON bytes.
281
282 Args:
283 record: The :class:`PatchRecord` to serialize.
284
285 Returns:
286 UTF-8 JSON bytes with no extra whitespace.
287 """
288 return json.dumps(_record_to_dict(record), separators=(",", ":")).encode("utf-8")
289
290 def deserialize_patch(data: bytes) -> PatchRecord:
291 """Deserialize *data* from UTF-8 JSON bytes.
292
293 Args:
294 data: UTF-8 JSON produced by :func:`serialize_patch`.
295
296 Returns:
297 A :class:`PatchRecord`.
298
299 Raises:
300 ValueError: JSON is invalid, missing required fields, or wrong types.
301 json.JSONDecodeError: *data* is not valid JSON.
302 """
303 d = json.loads(data)
304 if not isinstance(d, dict):
305 raise ValueError(f"Expected JSON object, got {type(d).__name__}")
306 _require_str(d, "patch_id")
307 return PatchRecord(
308 patch_id=_str(d, "patch_id"),
309 from_snapshot_id=_str(d, "from_snapshot_id"),
310 to_snapshot_id=_str(d, "to_snapshot_id"),
311 from_commit_id=_str(d, "from_commit_id"),
312 to_commit_id=_str(d, "to_commit_id"),
313 domain=_str(d, "domain"),
314 format_version=_str(d, "format_version"),
315 created_at=_str(d, "created_at"),
316 agent_id=_str(d, "agent_id"),
317 model_id=_str(d, "model_id"),
318 signer_public_key=_str(d, "signer_public_key"),
319 signature=_str(d, "signature"),
320 intent=_str(d, "intent"),
321 sem_ver_bump=_str(d, "sem_ver_bump"),
322 breaking_changes=list(d.get("breaking_changes") or []),
323 summary=_str(d, "summary"),
324 ops=list(d.get("ops") or []),
325 files_added=list(d.get("files_added") or []),
326 files_modified=list(d.get("files_modified") or []),
327 files_deleted=list(d.get("files_deleted") or []),
328 files_renamed=dict(d.get("files_renamed") or {}),
329 required_objects=list(d.get("required_objects") or []),
330 from_manifest=dict(d.get("from_manifest") or {}),
331 to_manifest=dict(d.get("to_manifest") or {}),
332 applicability=dict(d.get("applicability") or {}),
333 blobs=dict(d.get("blobs") or {}),
334 )
335
336 # ---------------------------------------------------------------------------
337 # Internal helpers
338 # ---------------------------------------------------------------------------
339
340 def _record_to_dict(record: PatchRecord) -> PatchRecordDict:
341 return {
342 "patch_id": record.patch_id,
343 "from_snapshot_id": record.from_snapshot_id,
344 "to_snapshot_id": record.to_snapshot_id,
345 "from_commit_id": record.from_commit_id,
346 "to_commit_id": record.to_commit_id,
347 "domain": record.domain,
348 "format_version": record.format_version,
349 "created_at": record.created_at,
350 "agent_id": record.agent_id,
351 "model_id": record.model_id,
352 "signer_public_key": record.signer_public_key,
353 "signature": record.signature,
354 "intent": record.intent,
355 "sem_ver_bump": record.sem_ver_bump,
356 "breaking_changes": list(record.breaking_changes),
357 "summary": record.summary,
358 "ops": list(record.ops),
359 "files_added": list(record.files_added),
360 "files_modified": list(record.files_modified),
361 "files_deleted": list(record.files_deleted),
362 "files_renamed": dict(record.files_renamed),
363 "required_objects": list(record.required_objects),
364 "from_manifest": dict(record.from_manifest),
365 "to_manifest": dict(record.to_manifest),
366 "applicability": dict(record.applicability),
367 "blobs": dict(record.blobs),
368 }
369
370 def _str(d: Mapping[str, JsonValue], key: str) -> str:
371 v = d.get(key, "")
372 return str(v) if v is not None else ""
373
374 def _require_str(d: Mapping[str, JsonValue], key: str) -> None:
375 if key not in d:
376 raise ValueError(f"Missing required field: {key!r}")
File History 1 commit
sha256:94f494a8f59e4b708ebb89e304209737ce34324a33aae83840a6f6b06d7b8d9d docs: add domain-extensibility.md — the two-axis breadth/de… Sonnet 5 2 days ago