gabriel / muse public

format_patch.py file-level

at sha256:5 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:6 chore(timeline): remove unused RationalRate import in entity.py · · Jul 10, 2026
1 """``muse format-patch [<ref>|-N]`` β€” export commits as content-addressed .mpatch files.
2
3 A Muse patch is a domain-aware, content-addressed, agent-first exchange object.
4 Unlike a traditional unified diff, a ``.mpatch`` file:
5
6 - Has a stable ``patch_id = sha256(canonical JSON)`` β€” any tampering is
7 immediately detectable before touching the working tree.
8 - Carries **Cohen action labels** on every op (``"inserted"``, ``"deleted"``,
9 ``"modified"``, ``"moved"``, ``"renamed"``), so agents know *what* each side
10 did without parsing raw diffs.
11 - Embeds **agent provenance**: ``agent_id``, ``model_id``, and signature.
12 - Declares **applicability**: which snapshot the target must be at, which
13 dimensions are unaffected, and whether the patch is conflict-free.
14 - Is **domain-tagged** so apply-time validation can verify plugin compatibility.
15
16 Output schema (JSON, ``--json``)::
17
18 {
19 "patch_id": "sha256:<64hex>",
20 "from_snapshot_id": "sha256:<64hex>",
21 "to_snapshot_id": "sha256:<64hex>",
22 "from_commit_id": "sha256:<64hex>",
23 "to_commit_id": "sha256:<64hex>",
24 "domain": "code",
25 "format_version": "1.0",
26 "created_at": "2026-01-01T00:00:00+00:00",
27 "agent_id": "",
28 "model_id": "",
29 "signer_public_key": "",
30 "signature": "",
31 "intent": "",
32 "sem_ver_bump": "minor",
33 "breaking_changes": [],
34 "summary": "1 file added",
35 "ops": [
36 {
37 "op": "insert",
38 "address": "hello.py",
39 "position": null,
40 "content_id": "sha256:<64hex>",
41 "content_summary": "hello.py added",
42 "action_label": "inserted"
43 }
44 ],
45 "files_added": ["hello.py"],
46 "files_modified": [],
47 "files_deleted": [],
48 "files_renamed": {},
49 "required_objects": ["sha256:<64hex>"],
50 "from_manifest": {},
51 "to_manifest": {"hello.py": "sha256:<64hex>"},
52 "applicability": {
53 "requires_snapshot": "sha256:<64hex>",
54 "independent_dimensions": [],
55 "conflict_free": true
56 },
57 "duration_ms": 4.521,
58 "exit_code": 0
59 }
60
61 Targeting
62 ---------
63 ``muse format-patch``
64 Produce one patch for the HEAD commit.
65
66 ``muse format-patch <branch-or-commit>``
67 Produce one patch for the named ref.
68
69 Flags
70 -----
71 ``--output-dir <dir>``
72 Write the patch to a ``.mpatch`` file in this directory. The filename
73 is ``<subject-slug>.mpatch``. The directory must already exist.
74
75 ``--json``
76 Emit the full patch JSON to stdout instead of writing a file.
77
78 ``--agent-id <id>``
79 Agent identifier to embed in the patch record (e.g. ``"claude-code"``).
80 Included in ``patch_id`` computation so different agents produce different
81 patch IDs for the same commit.
82
83 ``--model-id <id>``
84 Model identifier to embed in the patch record (e.g. ``"claude-sonnet-4-6"``).
85
86 ``--intent <text>``
87 Human-readable intent description to embed in the patch record. Useful
88 for agents to communicate *why* a patch was produced.
89
90 ``--no-blobs``
91 Omit base64-encoded object content from the patch file. ``required_objects``
92 still lists which content objects the target needs; blobs must be fetched
93 separately. Produces significantly smaller patch files for large repos.
94
95 Exit codes::
96
97 0 β€” success
98 1 β€” user error: bad ref, empty repo
99 2 β€” not a Muse repository
100 3 β€” I/O error
101
102 Examples::
103
104 muse format-patch --json
105 muse format-patch --output-dir /tmp/patches
106 muse format-patch feat/my-thing --json
107 """
108
109 import argparse
110 import base64
111 import json as _json
112 import logging
113 import pathlib
114 import re
115 import sys
116
117 from muse.core.errors import ExitCode
118 from muse.core.object_store import read_object
119 from muse.core.patch_record import PatchApplicability, PatchOp, PatchRecord, PatchRecordDict, compute_patch_id, serialize_patch
120 from muse.core.repo import require_repo
121 from muse.core.refs import read_ref
122 from muse.core.refs import (
123 get_head_commit_id,
124 read_current_branch,
125 )
126 from muse.core.commits import (
127 CommitRecord,
128 read_commit,
129 resolve_commit_ref,
130 )
131 from muse.core.snapshots import read_snapshot
132 from muse.core.validation import sanitize_display
133 from muse.core.types import NULL_LONG_ID, Manifest, long_id, now_utc_iso
134 from muse.core.paths import ref_path as _ref_path
135 from muse.core.envelope import EnvelopeJson, make_envelope
136 from muse.core.timing import start_timer
137 from muse.plugins.registry import read_domain
138
139 logger = logging.getLogger(__name__)
140
141 class _FormatPatchJson(EnvelopeJson):
142 """JSON output for ``muse format-patch --json``.
143
144 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
145 All remaining fields mirror :class:`~muse.core.patch_record.PatchRecord`.
146 """
147
148 patch_id: str
149 from_snapshot_id: str | None
150 to_snapshot_id: str
151 from_commit_id: str | None
152 to_commit_id: str
153 domain: str
154 format_version: str
155 created_at: str
156 agent_id: str | None
157 model_id: str | None
158 signer_public_key: str | None
159 signature: str | None
160 intent: str | None
161 sem_ver_bump: str | None
162 breaking_changes: list[str]
163 summary: str | None
164 ops: list[PatchOp]
165 files_added: list[str]
166 files_modified: list[str]
167 files_deleted: list[str]
168 files_renamed: dict[str, str]
169 required_objects: list[str]
170 from_manifest: dict[str, str]
171 to_manifest: dict[str, str]
172 applicability: PatchApplicability
173 blobs: dict[str, str]
174
175 _FORMAT_VERSION = "1.0"
176 _MAX_SUBJECT_LEN = 52
177
178 # ---------------------------------------------------------------------------
179 # Internal helpers
180 # ---------------------------------------------------------------------------
181
182 def _action_label(op_type: str) -> str:
183 """Map a DomainOp ``op`` field to a Cohen action label.
184
185 Args:
186 op_type: The ``"op"`` field value from a DomainOp dict.
187
188 Returns:
189 One of ``"inserted"``, ``"deleted"``, ``"modified"``, ``"moved"``,
190 ``"renamed"``.
191 """
192 if op_type == "insert":
193 return "inserted"
194 if op_type == "delete":
195 return "deleted"
196 if op_type in ("replace", "mutate", "patch"):
197 return "modified"
198 if op_type == "move":
199 return "moved"
200 if op_type == "rename":
201 return "renamed"
202 return "modified"
203
204 def _build_file_level_ops(
205 base_manifest: Manifest,
206 target_manifest: Manifest,
207 ) -> tuple[list[dict], list[str], list[str], list[str], dict[str, str]]:
208 """Derive file-level DomainOps from manifest deltas.
209
210 Computes added/modified/deleted sets and detects renames (same object ID
211 removed from one path and added at another). Returns a list of DomainOp-like
212 dicts (each with an ``action_label`` Cohen extension) plus the four
213 file-change collections.
214
215 Rename detection: when a path is deleted and a path is added that share the
216 same content object ID, the pair is treated as a rename rather than a
217 separate delete+insert. The deleted path maps to the added path in
218 ``files_renamed``; neither appears in ``files_added`` or ``files_deleted``.
219 A single ``"move"`` op with ``action_label="moved"`` is emitted.
220
221 Args:
222 base_manifest: Parent commit manifest.
223 target_manifest: This commit manifest.
224
225 Returns:
226 ``(ops, files_added, files_modified, files_deleted, files_renamed)``
227 where ``files_renamed`` maps old path β†’ new path.
228 """
229 base_paths = set(base_manifest)
230 target_paths = set(target_manifest)
231
232 raw_added = sorted(target_paths - base_paths)
233 raw_deleted = sorted(base_paths - target_paths)
234 modified = sorted(
235 p for p in base_paths & target_paths
236 if base_manifest[p] != target_manifest[p]
237 )
238
239 # ── Rename detection ──────────────────────────────────────────────────────
240 # Build OID β†’ [deleted paths] and OID β†’ [added paths] maps.
241 # A deleted path whose OID appears exactly once on each side is a rename.
242 deleted_by_oid: dict[str, list[str]] = {}
243 for path in raw_deleted:
244 oid = base_manifest[path]
245 deleted_by_oid.setdefault(oid, []).append(path)
246
247 added_by_oid: dict[str, list[str]] = {}
248 for path in raw_added:
249 oid = target_manifest[path]
250 added_by_oid.setdefault(oid, []).append(path)
251
252 files_renamed: dict[str, str] = {}
253 renamed_old: set[str] = set()
254 renamed_new: set[str] = set()
255
256 for oid, del_paths in deleted_by_oid.items():
257 add_paths = added_by_oid.get(oid, [])
258 if len(del_paths) == 1 and len(add_paths) == 1:
259 old_path = del_paths[0]
260 new_path = add_paths[0]
261 files_renamed[old_path] = new_path
262 renamed_old.add(old_path)
263 renamed_new.add(new_path)
264
265 added = [p for p in raw_added if p not in renamed_new]
266 deleted = [p for p in raw_deleted if p not in renamed_old]
267
268 ops: list[dict] = []
269
270 for path in added:
271 ops.append({
272 "op": "insert",
273 "address": path,
274 "position": None,
275 "content_id": target_manifest[path],
276 "content_summary": f"{sanitize_display(path)} added",
277 "action_label": "inserted",
278 })
279
280 for path in deleted:
281 ops.append({
282 "op": "delete",
283 "address": path,
284 "position": None,
285 "content_id": base_manifest[path],
286 "content_summary": f"{sanitize_display(path)} deleted",
287 "action_label": "deleted",
288 })
289
290 for path in modified:
291 ops.append({
292 "op": "replace",
293 "address": path,
294 "position": None,
295 "old_content_id": base_manifest[path],
296 "new_content_id": target_manifest[path],
297 "old_summary": f"{sanitize_display(path)} before",
298 "new_summary": f"{sanitize_display(path)} after",
299 "action_label": "modified",
300 })
301
302 for old_path, new_path in sorted(files_renamed.items()):
303 ops.append({
304 "op": "move",
305 "address": old_path,
306 "new_address": new_path,
307 "position": None,
308 "content_id": base_manifest[old_path],
309 "content_summary": f"{sanitize_display(old_path)} renamed to {sanitize_display(new_path)}",
310 "action_label": "moved",
311 })
312
313 return ops, added, modified, deleted, files_renamed
314
315 def _sem_ver_bump(commit_message: str, files_added: list[str], files_deleted: list[str]) -> str:
316 """Heuristic semantic version bump from commit message and file changes.
317
318 - ``major`` if message starts with ``break:`` / ``feat!:`` or contains
319 ``BREAKING CHANGE``.
320 - ``minor`` if message starts with ``feat:``, or new files are added.
321 - ``patch`` otherwise.
322
323 Args:
324 commit_message: The commit message string.
325 files_added: List of added file paths.
326 files_deleted: List of deleted file paths.
327
328 Returns:
329 ``"major"``, ``"minor"``, or ``"patch"``.
330 """
331 msg_lower = commit_message.lower().strip()
332 if msg_lower.startswith(("break:", "feat!:")) or "breaking change" in msg_lower:
333 return "major"
334 if msg_lower.startswith("feat:") or files_added:
335 return "minor"
336 return "patch"
337
338 def _breaking_changes(commit_message: str) -> list[str]:
339 """Extract breaking change descriptions from commit message.
340
341 Looks for lines starting with ``BREAKING CHANGE:`` and returns them.
342
343 Args:
344 commit_message: The full commit message string.
345
346 Returns:
347 List of breaking change description strings.
348 """
349 changes = []
350 for line in commit_message.splitlines():
351 stripped = line.strip()
352 if stripped.upper().startswith("BREAKING CHANGE:"):
353 changes.append(stripped[len("BREAKING CHANGE:"):].strip())
354 return changes
355
356 def _make_patch_filename(subject: str) -> str:
357 """Build a filesystem-safe ``.mpatch`` filename from a commit subject.
358
359 Args:
360 subject: Commit message subject (first line).
361
362 Returns:
363 Filename like ``"feat-add-hello.mpatch"``.
364 """
365 safe = re.sub(r"[^\x20-\x7e]", "", subject)
366 safe = re.sub(r"[/\\:*?\"<>|.]", "-", safe)
367 safe = re.sub(r"\s+", "-", safe.strip())
368 safe = re.sub(r"-{2,}", "-", safe)
369 safe = safe.strip("-")
370 if len(safe) > _MAX_SUBJECT_LEN:
371 safe = safe[:_MAX_SUBJECT_LEN].rstrip("-")
372 if not safe:
373 safe = "patch"
374 return f"{safe}.mpatch"
375
376 def _resolve_commit(root: pathlib.Path, treeish: str) -> CommitRecord:
377 """Resolve *treeish* to a CommitRecord.
378
379 Args:
380 root: Absolute repo root.
381 treeish: Branch name, commit ID prefix, or ``"HEAD"``.
382
383 Returns:
384 CommitRecord.
385
386 Raises:
387 SystemExit(USER_ERROR): ref not found or empty repo.
388 """
389 try:
390 branch = read_current_branch(root)
391
392 if treeish.upper() == "HEAD":
393 commit_id = get_head_commit_id(root, branch)
394 if not commit_id:
395 print("❌ Repository has no commits yet.", file=sys.stderr)
396 raise SystemExit(ExitCode.USER_ERROR)
397 commit = read_commit(root, commit_id)
398 else:
399 branch_ref = _ref_path(root, treeish)
400 commit_id = read_ref(branch_ref)
401 if commit_id is not None:
402 commit = read_commit(root, commit_id)
403 else:
404 commit = resolve_commit_ref(root, branch, treeish)
405
406 if commit is None:
407 print(
408 f"❌ '{sanitize_display(treeish)}' is not a known branch or commit ID.",
409 file=sys.stderr,
410 )
411 raise SystemExit(ExitCode.USER_ERROR)
412
413 return commit
414
415 except SystemExit:
416 raise
417 except Exception as exc:
418 print(f"❌ Failed to resolve '{sanitize_display(treeish)}': {exc}", file=sys.stderr)
419 raise SystemExit(ExitCode.USER_ERROR)
420
421 # ---------------------------------------------------------------------------
422 # Registration
423 # ---------------------------------------------------------------------------
424
425 def register(
426 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
427 ) -> None:
428 """Register the ``muse format-patch`` subcommand."""
429 parser = subparsers.add_parser(
430 "format-patch",
431 help="Export a commit as a content-addressed .mpatch file.",
432 description=__doc__,
433 formatter_class=argparse.RawDescriptionHelpFormatter,
434 )
435 parser.add_argument(
436 "treeish",
437 metavar="REF",
438 nargs="?",
439 default="HEAD",
440 help="Commit to export (default: HEAD). Branch name or commit ID.",
441 )
442 parser.add_argument(
443 "--output-dir", "-o",
444 dest="output_dir",
445 default=None,
446 metavar="DIR",
447 help="Directory to write the .mpatch file (must already exist).",
448 )
449 parser.add_argument(
450 "--json", "-j",
451 action="store_true",
452 dest="json_out",
453 help="Emit the full patch JSON to stdout.",
454 )
455 parser.add_argument(
456 "--agent-id",
457 dest="agent_id",
458 default="",
459 metavar="ID",
460 help="Agent identifier to embed in the patch record (e.g. 'claude-code').",
461 )
462 parser.add_argument(
463 "--model-id",
464 dest="model_id",
465 default="",
466 metavar="ID",
467 help="Model identifier to embed in the patch record (e.g. 'claude-sonnet-4-6').",
468 )
469 parser.add_argument(
470 "--intent",
471 dest="intent",
472 default="",
473 metavar="TEXT",
474 help="Human-readable intent description to embed in the patch record.",
475 )
476 parser.add_argument(
477 "--no-blobs",
478 action="store_true",
479 dest="no_blobs",
480 help="Omit base64 blob content from the patch file (required_objects still listed).",
481 )
482 parser.set_defaults(func=run)
483
484 # ---------------------------------------------------------------------------
485 # Run
486 # ---------------------------------------------------------------------------
487
488 def run(args: argparse.Namespace) -> None:
489 """Export a commit as a content-addressed Muse patch object.
490
491 Produces a ``.mpatch`` JSON file (or stdout with ``--json``) that can be
492 applied to another repo or agent working tree with ``muse apply-patch``.
493 The patch is content-addressed β€” identical commits always produce the same
494 patch ID, enabling deduplication across repos.
495
496 Agent quickstart
497 ----------------
498 ::
499
500 muse format-patch --json
501 muse format-patch HEAD~3 --json
502 muse format-patch HEAD --output-dir /tmp/patches --json
503 muse format-patch HEAD --no-blobs --json
504
505 JSON fields
506 -----------
507 patch_id Content-addressed ID of the patch object.
508 commit_id Source commit ID.
509 domain Domain name the patch belongs to.
510 from_manifest File manifest at the parent commit.
511 to_manifest File manifest after the commit.
512 blobs Embedded blob objects (omitted with ``--no-blobs``).
513
514 Exit codes
515 ----------
516 0 Success.
517 1 Bad ref or empty repository.
518 2 Not inside a Muse repository.
519 3 I/O error writing ``.mpatch`` file.
520 """
521 elapsed = start_timer()
522 treeish: str = args.treeish or "HEAD"
523 output_dir_raw: str | None = args.output_dir
524 json_out: bool = args.json_out
525 agent_id: str = getattr(args, "agent_id", "")
526 model_id: str = getattr(args, "model_id", "")
527 intent: str = getattr(args, "intent", "")
528 no_blobs: bool = getattr(args, "no_blobs", False)
529
530 root = require_repo()
531 domain = read_domain(root)
532
533 # ── Resolve commit ────────────────────────────────────────────────────────
534 commit = _resolve_commit(root, treeish)
535
536 # ── Load parent manifest ──────────────────────────────────────────────────
537 base_manifest: Manifest = {}
538 from_snapshot_id = NULL_LONG_ID # sentinel for initial commit
539 from_commit_id = ""
540
541 if commit.parent_commit_id:
542 parent = read_commit(root, commit.parent_commit_id)
543 if parent:
544 from_commit_id = parent.commit_id
545 snap = read_snapshot(root, parent.snapshot_id)
546 if snap:
547 base_manifest = dict(snap.manifest)
548 from_snapshot_id = parent.snapshot_id
549
550 # ── Load this commit's manifest ───────────────────────────────────────────
551 snap = read_snapshot(root, commit.snapshot_id)
552 target_manifest: Manifest = dict(snap.manifest) if snap else {}
553 to_snapshot_id = commit.snapshot_id
554
555 # ── Build ops and file lists ──────────────────────────────────────────────
556 ops, files_added, files_modified, files_deleted, files_renamed = _build_file_level_ops(
557 base_manifest, target_manifest
558 )
559
560 # ── Build delta manifests (changed paths only) ────────────────────────────
561 # Renamed paths: old path in from_manifest, new path in to_manifest
562 renamed_old = set(files_renamed.keys())
563 renamed_new = set(files_renamed.values())
564 changed_paths = set(files_added) | set(files_modified) | set(files_deleted) | renamed_old | renamed_new
565 from_manifest_delta = {p: base_manifest[p] for p in changed_paths if p in base_manifest}
566 to_manifest_delta = {p: target_manifest[p] for p in changed_paths if p in target_manifest}
567
568 # ── Required objects: to_manifest delta values ───────────────────────────
569 required_objects = sorted(set(to_manifest_delta.values()))
570
571 # ── Embed blobs for self-contained transport ──────────────────────────────
572 blobs: dict[str, str] = {}
573 if not no_blobs:
574 for oid in required_objects:
575 content = read_object(root, oid)
576 if content is not None:
577 blobs[oid] = base64.b64encode(content).decode("ascii")
578
579 # ── Semantic version impact ───────────────────────────────────────────────
580 message = commit.message or ""
581 subject = message.splitlines()[0] if message else ""
582 bump = _sem_ver_bump(message, files_added, files_deleted)
583 breaking = _breaking_changes(message)
584
585 # ── Summary ───────────────────────────────────────────────────────────────
586 parts = []
587 if files_added:
588 n = len(files_added)
589 parts.append(f"{n} file{'s' if n > 1 else ''} added")
590 if files_modified:
591 n = len(files_modified)
592 parts.append(f"{n} file{'s' if n > 1 else ''} modified")
593 if files_deleted:
594 n = len(files_deleted)
595 parts.append(f"{n} file{'s' if n > 1 else ''} deleted")
596 summary = ", ".join(parts) if parts else "no file changes"
597
598 # ── Applicability ─────────────────────────────────────────────────────────
599 applicability = {
600 "requires_snapshot": from_snapshot_id,
601 "independent_dimensions": [],
602 "conflict_free": True,
603 }
604
605 # ── Build PatchRecord ─────────────────────────────────────────────────────
606 # Use the commit's timestamp as created_at so patch_id is deterministic
607 # for the same commit regardless of when format-patch is invoked.
608 created_at = commit.committed_at.isoformat() if commit.committed_at else now_utc_iso()
609 rec = PatchRecord(
610 patch_id="",
611 from_snapshot_id=from_snapshot_id,
612 to_snapshot_id=to_snapshot_id,
613 from_commit_id=from_commit_id,
614 to_commit_id=commit.commit_id,
615 domain=domain,
616 format_version=_FORMAT_VERSION,
617 created_at=created_at,
618 agent_id=agent_id,
619 model_id=model_id,
620 signer_public_key="",
621 signature="",
622 intent=intent,
623 sem_ver_bump=bump,
624 breaking_changes=breaking,
625 summary=summary,
626 ops=ops,
627 files_added=files_added,
628 files_modified=files_modified,
629 files_deleted=files_deleted,
630 files_renamed=files_renamed,
631 required_objects=required_objects,
632 from_manifest=from_manifest_delta,
633 to_manifest=to_manifest_delta,
634 applicability=applicability,
635 blobs=blobs,
636 )
637 rec.patch_id = compute_patch_id(rec)
638
639 # ── Output ────────────────────────────────────────────────────────────────
640 if json_out:
641 print(_json.dumps(_FormatPatchJson(**make_envelope(elapsed), **_record_to_json_dict(rec)), separators=(",", ":")))
642 return
643
644 if output_dir_raw is not None:
645 out_dir = pathlib.Path(output_dir_raw)
646 if not out_dir.is_dir():
647 print(
648 f"❌ Output directory does not exist: {sanitize_display(output_dir_raw)}",
649 file=sys.stderr,
650 )
651 raise SystemExit(ExitCode.IO_ERROR)
652 filename = _make_patch_filename(subject)
653 dest = out_dir / filename
654 try:
655 dest.write_bytes(serialize_patch(rec))
656 except OSError as exc:
657 print(f"❌ Could not write {filename}: {exc}", file=sys.stderr)
658 raise SystemExit(ExitCode.IO_ERROR)
659 print(filename)
660 return
661
662 # Default: print filename hint
663 print(serialize_patch(rec).decode("utf-8"))
664
665 def _record_to_json_dict(rec: PatchRecord) -> PatchRecordDict:
666 """Convert a PatchRecord to a plain dict for JSON output."""
667 return {
668 "patch_id": rec.patch_id,
669 "from_snapshot_id": rec.from_snapshot_id,
670 "to_snapshot_id": rec.to_snapshot_id,
671 "from_commit_id": rec.from_commit_id,
672 "to_commit_id": rec.to_commit_id,
673 "domain": rec.domain,
674 "format_version": rec.format_version,
675 "created_at": rec.created_at,
676 "agent_id": rec.agent_id,
677 "model_id": rec.model_id,
678 "signer_public_key": rec.signer_public_key,
679 "signature": rec.signature,
680 "intent": rec.intent,
681 "sem_ver_bump": rec.sem_ver_bump,
682 "breaking_changes": list(rec.breaking_changes),
683 "summary": rec.summary,
684 "ops": list(rec.ops),
685 "files_added": list(rec.files_added),
686 "files_modified": list(rec.files_modified),
687 "files_deleted": list(rec.files_deleted),
688 "files_renamed": dict(rec.files_renamed),
689 "required_objects": list(rec.required_objects),
690 "from_manifest": dict(rec.from_manifest),
691 "to_manifest": dict(rec.to_manifest),
692 "applicability": dict(rec.applicability),
693 "blobs": dict(rec.blobs),
694 }