gitgrip 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. gitgrip-1.5.0.dist-info/METADATA +13 -0
  2. gitgrip-1.5.0.dist-info/RECORD +80 -0
  3. gitgrip-1.5.0.dist-info/WHEEL +5 -0
  4. gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
  5. gitgrip-1.5.0.dist-info/top_level.txt +2 -0
  6. gr2/__init__.py +0 -0
  7. gr2/overlay/__init__.py +6 -0
  8. gr2/overlay/activate.py +196 -0
  9. gr2/overlay/agent_manifest.py +138 -0
  10. gr2/overlay/cli.py +181 -0
  11. gr2/overlay/cross_repo.py +124 -0
  12. gr2/overlay/drivers.py +113 -0
  13. gr2/overlay/introspection.py +155 -0
  14. gr2/overlay/language_drivers.py +115 -0
  15. gr2/overlay/objects.py +412 -0
  16. gr2/overlay/perf.py +251 -0
  17. gr2/overlay/refs.py +36 -0
  18. gr2/overlay/trust.py +150 -0
  19. gr2/overlay/types.py +69 -0
  20. gr2/overlay/units.py +313 -0
  21. gr2/overlay/workspace_spec.py +59 -0
  22. gr2/prototypes/__init__.py +0 -0
  23. gr2/prototypes/cache_materialization_probe.py +190 -0
  24. gr2/prototypes/concurrent_event_stress.py +199 -0
  25. gr2/prototypes/concurrent_lease_stress.py +240 -0
  26. gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
  27. gr2/prototypes/contribution_protocol.py +665 -0
  28. gr2/prototypes/cross_mode_lane_stress.py +986 -0
  29. gr2/prototypes/jsonl_store.py +158 -0
  30. gr2/prototypes/lane_workspace_prototype.py +2088 -0
  31. gr2/prototypes/layout_model_probe.py +139 -0
  32. gr2/prototypes/propagation_daemon.py +546 -0
  33. gr2/prototypes/propagation_state_machine.py +1478 -0
  34. gr2/prototypes/python_exec_playground.py +194 -0
  35. gr2/prototypes/python_hook_runtime_playground.py +240 -0
  36. gr2/prototypes/python_migration_playground.py +144 -0
  37. gr2/prototypes/python_review_checkout_playground.py +242 -0
  38. gr2/prototypes/python_spec_apply_playground.py +282 -0
  39. gr2/prototypes/real_git_lane_materialization.py +248 -0
  40. gr2/prototypes/real_git_playground.py +334 -0
  41. gr2/prototypes/recall_lane_history.py +274 -0
  42. gr2/prototypes/repo_maintenance_prototype.py +659 -0
  43. gr2/prototypes/repo_transport_probe.py +147 -0
  44. gr2/python_cli/__init__.py +2 -0
  45. gr2/python_cli/__main__.py +6 -0
  46. gr2/python_cli/add.py +51 -0
  47. gr2/python_cli/app.py +2516 -0
  48. gr2/python_cli/branch.py +67 -0
  49. gr2/python_cli/channel_bridge.py +131 -0
  50. gr2/python_cli/clone_exec.py +1019 -0
  51. gr2/python_cli/commit.py +199 -0
  52. gr2/python_cli/config.py +291 -0
  53. gr2/python_cli/env_exec.py +419 -0
  54. gr2/python_cli/events.py +529 -0
  55. gr2/python_cli/execops.py +372 -0
  56. gr2/python_cli/failures.py +98 -0
  57. gr2/python_cli/file_exec.py +256 -0
  58. gr2/python_cli/gitops.py +226 -0
  59. gr2/python_cli/grip.py +1337 -0
  60. gr2/python_cli/grip_cli.py +493 -0
  61. gr2/python_cli/hooks.py +450 -0
  62. gr2/python_cli/launch_exec.py +786 -0
  63. gr2/python_cli/merge_verification.py +274 -0
  64. gr2/python_cli/migration.py +985 -0
  65. gr2/python_cli/open_gr_review.py +699 -0
  66. gr2/python_cli/platform.py +441 -0
  67. gr2/python_cli/pr.py +487 -0
  68. gr2/python_cli/project_review.py +314 -0
  69. gr2/python_cli/prune.py +365 -0
  70. gr2/python_cli/push.py +172 -0
  71. gr2/python_cli/review.py +462 -0
  72. gr2/python_cli/review_ephemeral.py +143 -0
  73. gr2/python_cli/review_run.py +621 -0
  74. gr2/python_cli/spec_apply.py +1285 -0
  75. gr2/python_cli/staging_cleanup.py +205 -0
  76. gr2/python_cli/syncops.py +920 -0
  77. gr2/python_cli/target.py +100 -0
  78. gr2/python_cli/workspace_snapshot.py +105 -0
  79. gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
  80. gr2_overlay/__init__.py +37 -0
@@ -0,0 +1,1285 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import dataclasses
5
+ import hashlib
6
+ import hmac
7
+ import importlib.resources
8
+ import json
9
+ import os
10
+ import secrets
11
+ import stat
12
+ import tomllib
13
+ import unicodedata
14
+ import weakref
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from types import MappingProxyType
18
+
19
+ from jsonschema import Draft202012Validator
20
+
21
+ from .events import EventType, emit_after_outcome
22
+ from .gitops import clone_repo, ensure_repo_cache, is_git_dir, is_git_repo, repo_dirty
23
+ from .hooks import HookContext, apply_file_projections, load_repo_hooks, run_lifecycle_stage
24
+
25
+
26
+ @dataclasses.dataclass(frozen=True)
27
+ class ValidationIssue:
28
+ level: str
29
+ code: str
30
+ message: str
31
+ path: str | None = None
32
+
33
+ def as_dict(self) -> dict[str, object]:
34
+ return dataclasses.asdict(self)
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class PlanOperation:
39
+ kind: str
40
+ subject: str
41
+ target_path: str
42
+ reason: str
43
+ details: dict[str, object]
44
+
45
+ def as_dict(self) -> dict[str, object]:
46
+ return dataclasses.asdict(self)
47
+
48
+
49
+ def workspace_spec_path(workspace_root: Path) -> Path:
50
+ return workspace_root / ".grip" / "workspace_spec.toml"
51
+
52
+
53
+ def workspace_cache_root(workspace_root: Path) -> Path:
54
+ return workspace_root / ".grip" / "cache" / "repos"
55
+
56
+
57
+ def repo_cache_path(workspace_root: Path, repo_name: str) -> Path:
58
+ return workspace_cache_root(workspace_root) / f"{repo_name}.git"
59
+
60
+
61
+ def load_workspace_spec_doc(workspace_root: Path) -> dict[str, object]:
62
+ spec_path = workspace_spec_path(workspace_root)
63
+ if not spec_path.exists():
64
+ raise SystemExit(
65
+ f"workspace spec not found: {spec_path}\n"
66
+ "run `gr2 workspace init <path>` first or create .grip/workspace_spec.toml explicitly"
67
+ )
68
+ with spec_path.open("rb") as fh:
69
+ return tomllib.load(fh)
70
+
71
+
72
+ def show_spec(workspace_root: Path, *, json_output: bool) -> str:
73
+ spec_path = workspace_spec_path(workspace_root)
74
+ if json_output:
75
+ return json.dumps(load_workspace_spec_doc(workspace_root), indent=2)
76
+ return spec_path.read_text()
77
+
78
+
79
+ def validate_spec(workspace_root: Path) -> list[ValidationIssue]:
80
+ issues: list[ValidationIssue] = []
81
+ spec = load_workspace_spec_doc(workspace_root)
82
+
83
+ workspace_name = str(spec.get("workspace_name", "")).strip()
84
+ if not workspace_name:
85
+ issues.append(
86
+ ValidationIssue(
87
+ level="error",
88
+ code="missing_workspace_name",
89
+ message="workspace spec workspace_name must not be empty",
90
+ path="workspace_name",
91
+ )
92
+ )
93
+
94
+ repo_names: set[str] = set()
95
+ for idx, repo in enumerate(spec.get("repos", [])):
96
+ name = str(repo.get("name", "")).strip()
97
+ path = str(repo.get("path", "")).strip()
98
+ url = str(repo.get("url", "")).strip()
99
+ if not name:
100
+ issues.append(
101
+ ValidationIssue("error", "missing_repo_name", "repo name must not be empty", f"repos[{idx}].name")
102
+ )
103
+ continue
104
+ if name in repo_names:
105
+ issues.append(
106
+ ValidationIssue("error", "duplicate_repo_name", f"duplicate repo '{name}'", f"repos[{idx}].name")
107
+ )
108
+ repo_names.add(name)
109
+ if not path:
110
+ issues.append(
111
+ ValidationIssue("error", "missing_repo_path", f"repo '{name}' path must not be empty", f"repos[{idx}].path")
112
+ )
113
+ if not url:
114
+ issues.append(
115
+ ValidationIssue("error", "missing_repo_url", f"repo '{name}' url must not be empty", f"repos[{idx}].url")
116
+ )
117
+ repo_root = workspace_root / path
118
+ if repo_root.exists() and not is_git_repo(repo_root):
119
+ issues.append(
120
+ ValidationIssue(
121
+ level="error",
122
+ code="repo_path_conflict",
123
+ message=f"repo path exists but is not a git repo: {repo_root}",
124
+ path=f"repos[{idx}].path",
125
+ )
126
+ )
127
+ cache_root = repo_cache_path(workspace_root, name)
128
+ if cache_root.exists() and not is_git_dir(cache_root):
129
+ issues.append(
130
+ ValidationIssue(
131
+ level="error",
132
+ code="repo_cache_conflict",
133
+ message=f"repo cache path exists but is not a bare git dir: {cache_root}",
134
+ path=f"repos[{idx}].name",
135
+ )
136
+ )
137
+ if repo_root.exists() and is_git_repo(repo_root):
138
+ try:
139
+ load_repo_hooks(repo_root)
140
+ except SystemExit as exc:
141
+ issues.append(
142
+ ValidationIssue(
143
+ level="error",
144
+ code="invalid_repo_hooks",
145
+ message=f"repo '{name}' has invalid .gr2/hooks.toml: {exc}",
146
+ path=f"repos[{idx}]",
147
+ )
148
+ )
149
+
150
+ unit_names: set[str] = set()
151
+ for idx, unit in enumerate(spec.get("units", [])):
152
+ name = str(unit.get("name", "")).strip()
153
+ path = str(unit.get("path", "")).strip()
154
+ repos = [str(item) for item in unit.get("repos", [])]
155
+ if not name:
156
+ issues.append(
157
+ ValidationIssue("error", "missing_unit_name", "unit name must not be empty", f"units[{idx}].name")
158
+ )
159
+ continue
160
+ if name in unit_names:
161
+ issues.append(
162
+ ValidationIssue("error", "duplicate_unit_name", f"duplicate unit '{name}'", f"units[{idx}].name")
163
+ )
164
+ unit_names.add(name)
165
+ if not path:
166
+ issues.append(
167
+ ValidationIssue("error", "missing_unit_path", f"unit '{name}' path must not be empty", f"units[{idx}].path")
168
+ )
169
+ unit_root = workspace_root / path
170
+ if unit_root.exists() and unit_root.is_file():
171
+ issues.append(
172
+ ValidationIssue(
173
+ "error",
174
+ "unit_path_conflict",
175
+ f"unit path exists as a file: {unit_root}",
176
+ f"units[{idx}].path",
177
+ )
178
+ )
179
+ missing = [repo for repo in repos if repo not in repo_names]
180
+ for repo_name in missing:
181
+ issues.append(
182
+ ValidationIssue(
183
+ "error",
184
+ "missing_unit_repo",
185
+ f"unit '{name}' references missing repo '{repo_name}'",
186
+ f"units[{idx}].repos",
187
+ )
188
+ )
189
+
190
+ return issues
191
+
192
+
193
+ def render_validation(issues: list[ValidationIssue]) -> str:
194
+ if not issues:
195
+ return "WorkspaceSpec\n- valid\n"
196
+ lines = ["WorkspaceSpec", "LEVEL\tCODE\tPATH\tMESSAGE"]
197
+ for issue in issues:
198
+ lines.append(f"{issue.level}\t{issue.code}\t{issue.path or '-'}\t{issue.message}")
199
+ return "\n".join(lines)
200
+
201
+
202
+ def build_plan(workspace_root: Path) -> tuple[dict[str, object], list[PlanOperation]]:
203
+ issues = validate_spec(workspace_root)
204
+ errors = [issue for issue in issues if issue.level == "error"]
205
+ if errors:
206
+ rendered = "\n".join(f"- {issue.message}" for issue in errors)
207
+ raise SystemExit(f"workspace spec validation failed:\n{rendered}")
208
+
209
+ spec = load_workspace_spec_doc(workspace_root)
210
+ operations: list[PlanOperation] = []
211
+
212
+ for repo in spec.get("repos", []):
213
+ repo_name = str(repo["name"])
214
+ repo_path = workspace_root / str(repo["path"])
215
+ cache_path = repo_cache_path(workspace_root, repo_name)
216
+ if not cache_path.exists():
217
+ operations.append(
218
+ PlanOperation(
219
+ kind="seed_repo_cache",
220
+ subject=repo_name,
221
+ target_path=str(cache_path),
222
+ reason="repo cache missing",
223
+ details={"url": str(repo["url"])},
224
+ )
225
+ )
226
+ if not repo_path.exists():
227
+ operations.append(
228
+ PlanOperation(
229
+ kind="clone_repo",
230
+ subject=repo_name,
231
+ target_path=str(repo_path),
232
+ reason="repo path missing",
233
+ details={"url": str(repo["url"]), "cache_path": str(cache_path)},
234
+ )
235
+ )
236
+
237
+ for unit in spec.get("units", []):
238
+ unit_name = str(unit["name"])
239
+ unit_root = workspace_root / str(unit["path"])
240
+ unit_toml = unit_root / "unit.toml"
241
+ if not unit_root.exists():
242
+ operations.append(
243
+ PlanOperation(
244
+ kind="create_unit_root",
245
+ subject=unit_name,
246
+ target_path=str(unit_root),
247
+ reason="unit path missing",
248
+ details={"repos": [str(repo) for repo in unit.get("repos", [])]},
249
+ )
250
+ )
251
+ if not unit_toml.exists():
252
+ operations.append(
253
+ PlanOperation(
254
+ kind="write_unit_metadata",
255
+ subject=unit_name,
256
+ target_path=str(unit_toml),
257
+ reason="unit metadata missing",
258
+ details={"repos": [str(repo) for repo in unit.get("repos", [])]},
259
+ )
260
+ )
261
+
262
+ # grip#539: computed unconditionally, not gated on unit_root/unit_toml
263
+ # already existing. A brand-new unit's declared repos are trivially
264
+ # "missing" too (unit_root doesn't exist yet, so (unit_root / r).exists()
265
+ # is False for every r) -- the old guard meant a first apply published
266
+ # the unit shell without scheduling its clones, requiring a second,
267
+ # separate apply to notice. Ordered after create_unit_root/
268
+ # write_unit_metadata in this loop, so apply_plan's execution (which
269
+ # processes operations in list order) creates the directory before
270
+ # trying to clone into it.
271
+ declared_repos = [str(r) for r in unit.get("repos", [])]
272
+ missing_repos = [r for r in declared_repos if not (unit_root / r).exists()]
273
+ if missing_repos:
274
+ operations.append(
275
+ PlanOperation(
276
+ kind="converge_unit_repos",
277
+ subject=unit_name,
278
+ target_path=str(unit_root),
279
+ reason=f"missing repo checkouts: {', '.join(missing_repos)}",
280
+ details={"missing_repos": missing_repos, "all_repos": declared_repos},
281
+ )
282
+ )
283
+
284
+ return spec, operations
285
+
286
+
287
+ def render_plan(operations: list[PlanOperation]) -> str:
288
+ if not operations:
289
+ return "ExecutionPlan\n- no changes required\n"
290
+ lines = ["ExecutionPlan", "KIND\tSUBJECT\tTARGET\tREASON"]
291
+ for op in operations:
292
+ lines.append(f"{op.kind}\t{op.subject}\t{op.target_path}\t{op.reason}")
293
+ return "\n".join(lines)
294
+
295
+
296
+ def apply_plan(workspace_root: Path, *, yes: bool, manual_hooks: bool = False) -> dict[str, object]:
297
+ spec, operations = build_plan(workspace_root)
298
+ if len(operations) > 3 and not yes:
299
+ raise SystemExit("plan contains more than 3 operations; rerun with --yes to apply it")
300
+
301
+ applied: list[str] = []
302
+ materialized_repos: list[dict[str, object]] = []
303
+ for op in operations:
304
+ if op.kind == "clone_repo":
305
+ repo_spec = _find_repo(spec, op.subject)
306
+ repo_root = workspace_root / str(repo_spec["path"])
307
+ cache_path = repo_cache_path(workspace_root, str(repo_spec["name"]))
308
+ first_materialize = clone_repo(str(repo_spec["url"]), repo_root, reference_repo_root=cache_path)
309
+ hook_payload = _run_materialize_hooks(
310
+ workspace_root,
311
+ repo_root,
312
+ str(repo_spec["name"]),
313
+ first_materialize,
314
+ manual_hooks=manual_hooks,
315
+ )
316
+ for projection in hook_payload["projected_files"]:
317
+ emit_after_outcome(
318
+ event_type=EventType.WORKSPACE_FILE_PROJECTED,
319
+ workspace_root=workspace_root,
320
+ actor="system",
321
+ owner_unit="workspace",
322
+ payload={
323
+ "repo": str(repo_spec["name"]),
324
+ "kind": projection["kind"],
325
+ "src": projection["src"],
326
+ "dest": projection["dest"],
327
+ },
328
+ )
329
+ materialized_repos.append({"repo": str(repo_spec["name"]), "first_materialize": first_materialize})
330
+ applied.append(f"cloned repo '{op.subject}' into {repo_root}")
331
+ elif op.kind == "seed_repo_cache":
332
+ repo_spec = _find_repo(spec, op.subject)
333
+ cache_path = repo_cache_path(workspace_root, str(repo_spec["name"]))
334
+ created = ensure_repo_cache(str(repo_spec["url"]), cache_path)
335
+ if created:
336
+ applied.append(f"seeded repo cache for '{op.subject}' at {cache_path}")
337
+ else:
338
+ applied.append(f"refreshed repo cache for '{op.subject}' at {cache_path}")
339
+ elif op.kind == "create_unit_root":
340
+ unit_root = Path(op.target_path)
341
+ unit_root.mkdir(parents=True, exist_ok=True)
342
+ applied.append(f"created unit root for '{op.subject}' at {unit_root}")
343
+ elif op.kind == "write_unit_metadata":
344
+ unit_spec = _find_unit(spec, op.subject)
345
+ unit_root = workspace_root / str(unit_spec["path"])
346
+ unit_root.mkdir(parents=True, exist_ok=True)
347
+ unit_toml = unit_root / "unit.toml"
348
+ unit_toml.write_text(render_unit_toml(unit_spec))
349
+ applied.append(f"wrote unit metadata for '{op.subject}'")
350
+ elif op.kind == "converge_unit_repos":
351
+ unit_spec = _find_unit(spec, op.subject)
352
+ unit_root = workspace_root / str(unit_spec["path"])
353
+ missing = [str(r) for r in op.details.get("missing_repos", [])]
354
+ converged: list[str] = []
355
+ for repo_name in missing:
356
+ repo_spec = _find_repo(spec, repo_name)
357
+ clone_dest = unit_root / repo_name
358
+ cache_path = repo_cache_path(workspace_root, str(repo_spec["name"]))
359
+ first_materialize = clone_repo(
360
+ str(repo_spec["url"]), clone_dest, reference_repo_root=cache_path,
361
+ )
362
+ if first_materialize:
363
+ converged.append(repo_name)
364
+ materialized_repos.append({"repo": repo_name, "first_materialize": True})
365
+ unit_toml = unit_root / "unit.toml"
366
+ unit_toml.write_text(render_unit_toml(unit_spec))
367
+ applied.append(f"converged unit '{op.subject}': cloned {', '.join(converged)}")
368
+ else:
369
+ raise SystemExit(f"unknown plan operation kind: {op.kind}")
370
+
371
+ if applied:
372
+ _record_apply_state(workspace_root, applied)
373
+ if materialized_repos:
374
+ emit_after_outcome(
375
+ event_type=EventType.WORKSPACE_MATERIALIZED,
376
+ workspace_root=workspace_root,
377
+ actor="system",
378
+ owner_unit="workspace",
379
+ payload={"repos": materialized_repos},
380
+ )
381
+
382
+ return {
383
+ "workspace_root": str(workspace_root),
384
+ "applied": applied,
385
+ "operation_count": len(operations),
386
+ }
387
+
388
+
389
+ def render_apply_result(payload: dict[str, object]) -> str:
390
+ applied = [str(item) for item in payload.get("applied", [])]
391
+ lines = ["ApplyResult", f"workspace_root = {payload['workspace_root']}", f"operation_count = {payload['operation_count']}"]
392
+ if not applied:
393
+ lines.append("- no changes applied")
394
+ return "\n".join(lines)
395
+ lines.append("ACTIONS")
396
+ lines.extend(f"- {item}" for item in applied)
397
+ return "\n".join(lines)
398
+
399
+
400
+ def _find_repo(spec: dict[str, object], repo_name: str) -> dict[str, object]:
401
+ for repo in spec.get("repos", []):
402
+ if str(repo.get("name")) == repo_name:
403
+ return repo
404
+ raise SystemExit(f"repo not found in workspace spec: {repo_name}")
405
+
406
+
407
+ def _find_unit(spec: dict[str, object], unit_name: str) -> dict[str, object]:
408
+ for unit in spec.get("units", []):
409
+ if str(unit.get("name")) == unit_name:
410
+ return unit
411
+ raise SystemExit(f"unit not found in workspace spec: {unit_name}")
412
+
413
+
414
+ def _run_materialize_hooks(
415
+ workspace_root: Path,
416
+ repo_root: Path,
417
+ repo_name: str,
418
+ first_materialize: bool,
419
+ *,
420
+ manual_hooks: bool = False,
421
+ ) -> dict[str, list[dict[str, object]]]:
422
+ hooks = load_repo_hooks(repo_root)
423
+ if not hooks:
424
+ return {"projected_files": []}
425
+ ctx = HookContext(
426
+ workspace_root=workspace_root,
427
+ unit_root=workspace_root,
428
+ lane_root=repo_root,
429
+ repo_root=repo_root,
430
+ repo_name=repo_name,
431
+ lane_owner="workspace",
432
+ lane_subject=repo_name,
433
+ lane_name="workspace",
434
+ )
435
+ projections = apply_file_projections(hooks, ctx)
436
+ run_lifecycle_stage(
437
+ hooks,
438
+ "on_materialize",
439
+ ctx,
440
+ repo_dirty=repo_dirty(repo_root),
441
+ first_materialize=first_materialize,
442
+ allow_manual=manual_hooks,
443
+ )
444
+ projected_files: list[dict[str, object]] = []
445
+ for result in projections:
446
+ if result.status != "applied" or not result.src or not result.dest:
447
+ continue
448
+ projected_files.append(
449
+ {
450
+ "kind": result.name.split(":", 1)[0],
451
+ "src": _relative_workspace_path(workspace_root, Path(result.src)),
452
+ "dest": _relative_workspace_path(workspace_root, Path(result.dest)),
453
+ }
454
+ )
455
+ return {"projected_files": projected_files}
456
+
457
+
458
+ def _relative_workspace_path(workspace_root: Path, path: Path) -> str:
459
+ return os.path.relpath(path, workspace_root)
460
+
461
+
462
+ def render_unit_toml(unit_spec: dict[str, object]) -> str:
463
+ repos = [str(repo) for repo in unit_spec.get("repos", [])]
464
+ repos_str = "[" + ", ".join(f'"{repo}"' for repo in repos) + "]"
465
+ lines = [
466
+ f'name = "{unit_spec["name"]}"',
467
+ 'kind = "unit"',
468
+ f"repos = {repos_str}",
469
+ ]
470
+ return "\n".join(lines) + "\n"
471
+
472
+
473
+ def _record_apply_state(workspace_root: Path, actions: list[str]) -> None:
474
+ state_dir = workspace_root / ".grip" / "state"
475
+ state_dir.mkdir(parents=True, exist_ok=True)
476
+ state_path = state_dir / "applied.toml"
477
+ timestamp = datetime.now(UTC).isoformat()
478
+ content = [
479
+ "[[applied]]",
480
+ f'timestamp = "{timestamp}"',
481
+ "actions = [" + ", ".join(json.dumps(action) for action in actions) + "]",
482
+ "",
483
+ ]
484
+ if state_path.exists():
485
+ existing = state_path.read_text().rstrip()
486
+ state_path.write_text(existing + "\n\n" + "\n".join(content))
487
+ else:
488
+ state_path.write_text("\n".join(content))
489
+
490
+
491
+ # ---------------------------------------------------------------------------
492
+ # Neutral MaterializationPlan v1 -- plan contract (S4-A)
493
+ #
494
+ # This module ships the PLAN-LEVEL contract only: schema conformance,
495
+ # identity-freedom, opaque-token safety, WorkspaceSpec binding, path
496
+ # canonicalization, destination-collision detection, and durable receipt
497
+ # publication. It deliberately ships NO operation execution -- the clone,
498
+ # staging/project_file, and venv/editable handlers arrive in S4-B/C/D,
499
+ # each with its own domain validation and mutation set (grip#797 split).
500
+ #
501
+ # Carve rationale: every guarantee here is enforced before any handler
502
+ # could run, so it is reviewable and complete on its own, and landing it
503
+ # first cannot ship a half-hardened operation path.
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ class MaterializationPlanError(Exception):
508
+ pass
509
+
510
+
511
+ # Capability seal. A ValidatedPlan can only be minted by
512
+ # validate_materialization_plan, so a receipt cannot be published from a
513
+ # plan that was never validated -- Atlas P1: the writer previously accepted
514
+ # the raw live plan and an arbitrary result list, which let a schema-invalid
515
+ # plan_id escape the receipt directory and let an unvalidated result graph
516
+ # be persisted verbatim.
517
+ #
518
+ # This was an opaque sentinel object, keyed on IDENTITY. Sentinel's witness:
519
+ # dataclasses.replace() re-invokes __init__ with the existing field values,
520
+ # so the real token rode into a modified shell and publication used the
521
+ # altered plan_id -- writing .grip/escaped.json outside the receipt
522
+ # directory. Identity is copyable; the capability has to bind CONTENT.
523
+ #
524
+ # Process-local and never persisted: this is an in-process capability, not a
525
+ # credential. It cannot be recomputed by a caller who did not go through
526
+ # validation, which is the whole point.
527
+ _CAPABILITY_SECRET = secrets.token_bytes(32)
528
+
529
+
530
+ def _deep_freeze(value: object) -> object:
531
+ """Recursively convert a JSON-shaped graph into a read-only one.
532
+
533
+ dataclasses.dataclass(frozen=True) freezes the field BINDING, not the
534
+ graph the field points at -- so a `plan` field holding a live dict is
535
+ mutable through anyone who holds the capability. Mappings become
536
+ MappingProxyType and sequences become tuples, which closes both the
537
+ item-assignment and the append/extend routes."""
538
+ if isinstance(value, dict):
539
+ return MappingProxyType({k: _deep_freeze(v) for k, v in value.items()})
540
+ if isinstance(value, list):
541
+ return tuple(_deep_freeze(v) for v in value)
542
+ return value
543
+
544
+
545
+ # Provenance registry: the instances this validator actually minted.
546
+ #
547
+ # Orthogonal to the seal, and deliberately so. The seal binds CONTENT, which
548
+ # closes replace() and in-place edits; provenance binds ORIGIN, which closes
549
+ # copying that preserves authority without changing any field. They fail in
550
+ # different ways, which is what makes them defense in depth rather than one
551
+ # guard shadowing another.
552
+ #
553
+ # Weak, so holding a capability never keeps it alive; entries vanish with the
554
+ # object. eq=False on the dataclass keeps identity hashing (a MappingProxyType
555
+ # field is unhashable anyway) -- and two capabilities over equal facts SHOULD
556
+ # be distinct capabilities, which is exactly what identity semantics give.
557
+ _MINTED_CAPABILITIES: weakref.WeakSet = weakref.WeakSet()
558
+
559
+
560
+ @dataclasses.dataclass(frozen=True, eq=False)
561
+ class ValidatedPlan:
562
+ """Proof that a plan passed the full v1 contract, plus the facts a
563
+ publisher needs to bind its evidence to that plan.
564
+
565
+ Immutable and unforgeable-by-accident: the token check means holding one
566
+ of these IS the evidence of validation, so publication has a capability
567
+ to demand rather than a convention to trust.
568
+
569
+ Every field here is a mint-time CAPTURE, not a view onto something a
570
+ caller still holds (Atlas final re-gate). The earlier version aliased the
571
+ caller's dict and recomputed the hash at publication time, so mutating
572
+ the plan after validation produced a receipt attesting to a graph that
573
+ was never validated -- the capability proved one graph had been checked
574
+ while vouching for another. Publication now reads captured facts only."""
575
+
576
+ plan: MappingProxyType
577
+ plan_id: str
578
+ unit_key: str
579
+ schema_version: int
580
+ workspace_spec_sha256: str
581
+ operation_kinds: tuple[str, ...]
582
+ plan_hash: str
583
+ _seal: str = dataclasses.field(repr=False)
584
+
585
+ def __post_init__(self) -> None:
586
+ # Provenance cannot be checked here: registration happens after
587
+ # construction, so this instance is not in the registry yet.
588
+ self.verify()
589
+
590
+ def verify(self, *, require_provenance: bool = False) -> None:
591
+ """Re-derive the seal from the current contents and compare.
592
+
593
+ Called at construction AND at every use. Construction-time checking
594
+ alone is not enough: `frozen=True` blocks __setattr__, not
595
+ object.__setattr__, so an already-minted capability can still be
596
+ edited in place. Re-deriving at use means the fields a publisher
597
+ reads are provably the fields that were sealed.
598
+
599
+ `require_provenance` adds the origin check, which only makes sense at
600
+ USE. Copying a capability preserves every field and therefore every
601
+ content check; only origin can refuse it."""
602
+ # The hash must actually describe the snapshot, so swapping the graph
603
+ # (with or without a matching hash) cannot survive.
604
+ if compute_plan_hash(self.plan) != self.plan_hash:
605
+ raise MaterializationPlanError(
606
+ "ValidatedPlan capability is invalid: plan_hash does not describe its plan snapshot"
607
+ )
608
+ # ...and the snapshot must agree with every fact published beside it,
609
+ # so the two can never diverge into "sealed but inconsistent".
610
+ snapshot_facts = (
611
+ self.plan.get("plan_id"),
612
+ self.plan.get("unit_key"),
613
+ self.plan.get("schema_version"),
614
+ self.plan.get("workspace_spec_sha256"),
615
+ tuple(str(op.get("kind")) for op in self.plan.get("operations", ())),
616
+ )
617
+ if snapshot_facts != (
618
+ self.plan_id,
619
+ self.unit_key,
620
+ self.schema_version,
621
+ self.workspace_spec_sha256,
622
+ tuple(self.operation_kinds),
623
+ ):
624
+ raise MaterializationPlanError(
625
+ "ValidatedPlan capability is invalid: published facts disagree with the plan snapshot"
626
+ )
627
+ expected = _capability_seal(
628
+ plan_hash=self.plan_hash,
629
+ plan_id=self.plan_id,
630
+ unit_key=self.unit_key,
631
+ schema_version=self.schema_version,
632
+ workspace_spec_sha256=self.workspace_spec_sha256,
633
+ operation_kinds=self.operation_kinds,
634
+ )
635
+ if not hmac.compare_digest(str(self._seal), expected):
636
+ raise MaterializationPlanError(
637
+ "ValidatedPlan capability is invalid: it was not minted by "
638
+ "validate_materialization_plan for these exact facts"
639
+ )
640
+ if require_provenance and self not in _MINTED_CAPABILITIES:
641
+ raise MaterializationPlanError(
642
+ "ValidatedPlan capability is invalid: it is not one this validator "
643
+ "minted -- a copy preserves every field but not its provenance"
644
+ )
645
+
646
+ def consume(self, op_results: object) -> _ConsumptionBinding:
647
+ """Verify once, then take ONE immutable reading of everything a
648
+ publisher will use.
649
+
650
+ This closes the check/use window Atlas found. Verifying and then
651
+ re-reading `self.plan_id` to build a filename is a live read: a
652
+ caller-controlled callback (his was `list.__iter__` on the evidence)
653
+ runs in between and mutates the shell, so the receipt is written
654
+ under an identity that was never verified.
655
+
656
+ Two details carry the fix:
657
+
658
+ The facts come from `self.plan`, the FROZEN snapshot, not from the
659
+ shell fields. The snapshot cannot be mutated at all, so deriving from
660
+ it makes a post-verify shell edit irrelevant rather than merely
661
+ detected. There is deliberately no second seal check after the
662
+ callback -- it would be unreachable, and an unreachable guard is
663
+ worse than none because it reads as protection while being untestable
664
+ at its own level.
665
+
666
+ The ORDER is load-bearing: facts are captured before the evidence is
667
+ materialized, because materializing is what runs caller code."""
668
+ self.verify(require_provenance=True)
669
+
670
+ # Captured first -- no caller code has run yet at this point.
671
+ plan = self.plan
672
+ facts = {
673
+ "plan_id": str(plan["plan_id"]),
674
+ "unit_key": str(plan["unit_key"]),
675
+ "schema_version": int(plan["schema_version"]),
676
+ "workspace_spec_sha256": str(plan["workspace_spec_sha256"]),
677
+ "operation_kinds": tuple(str(op["kind"]) for op in plan["operations"]),
678
+ "plan_hash": compute_plan_hash(plan),
679
+ }
680
+
681
+ # ...and only now touch the caller's object, exactly once.
682
+ if not isinstance(op_results, list):
683
+ raise MaterializationPlanError("receipt evidence must be a list of operation results")
684
+ return _ConsumptionBinding(evidence=tuple(_plain_json(list(op_results))), **facts)
685
+
686
+
687
+ def _plain_json(value: object) -> object:
688
+ """Materialize a caller-supplied graph into plain JSON types, once.
689
+
690
+ Two jobs. It detaches the value from anything the caller still holds, and
691
+ it normalizes away container subclasses -- so nothing downstream, json
692
+ serialization included, can re-enter caller code and get a second,
693
+ different answer."""
694
+ if isinstance(value, (dict, MappingProxyType)):
695
+ return {str(k): _plain_json(v) for k, v in value.items()}
696
+ if isinstance(value, (list, tuple)):
697
+ return [_plain_json(v) for v in value]
698
+ return value
699
+
700
+
701
+ @dataclasses.dataclass(frozen=True)
702
+ class _ConsumptionBinding:
703
+ """The single immutable reading taken at the verified consumption
704
+ instant (Atlas's use-time closure).
705
+
706
+ Every field a publisher needs, captured together, so nothing downstream
707
+ ever reads the live capability shell or the caller's evidence again."""
708
+
709
+ plan_id: str
710
+ unit_key: str
711
+ schema_version: int
712
+ workspace_spec_sha256: str
713
+ operation_kinds: tuple[str, ...]
714
+ plan_hash: str
715
+ evidence: tuple
716
+
717
+
718
+ def _capability_seal(
719
+ *,
720
+ plan_hash: str,
721
+ plan_id: str,
722
+ unit_key: str,
723
+ schema_version: int,
724
+ workspace_spec_sha256: str,
725
+ operation_kinds: tuple[str, ...],
726
+ ) -> str:
727
+ """Bind the seal to CONTENT, not to object identity.
728
+
729
+ Every fact publication consumes is covered, so altering any one of them
730
+ -- by dataclasses.replace, by object.__setattr__, or by hand-building a
731
+ shell -- produces a seal that no longer matches. Takes values rather than
732
+ an instance so the mint can compute it before the object exists."""
733
+ payload = json.dumps(
734
+ [
735
+ plan_hash,
736
+ plan_id,
737
+ unit_key,
738
+ schema_version,
739
+ workspace_spec_sha256,
740
+ list(operation_kinds),
741
+ ],
742
+ sort_keys=True,
743
+ separators=(",", ":"),
744
+ ensure_ascii=False,
745
+ ).encode("utf-8")
746
+ return hmac.new(_CAPABILITY_SECRET, payload, hashlib.sha256).hexdigest()
747
+
748
+
749
+ # The normative MaterializationPlan v1 wire contract. A hand-rolled
750
+ # validator is a separate, looser contract by construction -- nine plans the
751
+ # pinned schema rejects were accepted by an earlier hand version, and
752
+ # schema_version=True slipped a `!= 1` check because bool is an int subclass
753
+ # in Python (True == 1) while JSON Schema's const:1 distinguishes the types.
754
+ # The packaged bytes are verified against the pinned SHA at load and FAIL
755
+ # CLOSED, so a tampered or unpinned schema refuses to validate at all rather
756
+ # than silently enforcing something else.
757
+ _PLAN_SCHEMA_SHA256 = "a5061501ba6651d7432d87d57f1c85902e5dec076f860a47faa299f5f590231c"
758
+ _PLAN_SCHEMA_RESOURCE = "schemas/gr2-materialization-plan-v1.schema.json"
759
+ _plan_validator: Draft202012Validator | None = None
760
+
761
+ _VALID_OPERATION_KINDS = frozenset({"clone", "venv", "editable_install", "project_file"})
762
+
763
+
764
+ def _read_plan_schema_bytes() -> bytes:
765
+ """importlib.resources is the real (installed) path; the sibling-directory
766
+ fallback covers the in-repo pytest context, where conftest.py injects a
767
+ bare `gr2` module without a __spec__ and resource traversal fails. Either
768
+ way the bytes are SHA-verified before use, so WHERE they load from cannot
769
+ weaken WHAT gets enforced."""
770
+ try:
771
+ return (importlib.resources.files("gr2") / _PLAN_SCHEMA_RESOURCE).read_bytes()
772
+ except Exception:
773
+ return (Path(__file__).resolve().parent.parent / "gr2" / _PLAN_SCHEMA_RESOURCE).read_bytes()
774
+
775
+
776
+ def _load_plan_validator() -> Draft202012Validator:
777
+ global _plan_validator
778
+ if _plan_validator is None:
779
+ raw = _read_plan_schema_bytes()
780
+ actual = hashlib.sha256(raw).hexdigest()
781
+ if actual != _PLAN_SCHEMA_SHA256:
782
+ raise MaterializationPlanError(
783
+ f"packaged MaterializationPlan v1 schema hash mismatch: expected "
784
+ f"{_PLAN_SCHEMA_SHA256}, got {actual} -- refusing to validate against "
785
+ "an unpinned schema"
786
+ )
787
+ _plan_validator = Draft202012Validator(json.loads(raw))
788
+ return _plan_validator
789
+
790
+
791
+ # MaterializationPlan v1: "The production plan must not contain: agent display
792
+ # name, persistent agent ID, role, org or project, channel, entitlement
793
+ # result or reason, secret reference or value, memory body." Checked
794
+ # recursively as defence in depth -- the per-kind ALLOWLIST below is what
795
+ # actually proves identity-freedom, since a blacklist only catches names
796
+ # someone thought to enumerate.
797
+ _FORBIDDEN_IDENTITY_KEYS = frozenset(
798
+ {
799
+ "agent_name",
800
+ "agent_id",
801
+ "persistent_identity_ref",
802
+ "role",
803
+ "org",
804
+ "project",
805
+ "channel",
806
+ "channels",
807
+ "entitlement",
808
+ "entitlement_reason",
809
+ "secret",
810
+ "secret_ref",
811
+ "memory",
812
+ "memory_body",
813
+ }
814
+ )
815
+
816
+
817
+ def _reject_identity_fields_recursive(value: object, *, path: str) -> None:
818
+ if isinstance(value, dict):
819
+ present = _FORBIDDEN_IDENTITY_KEYS & value.keys()
820
+ if present:
821
+ raise MaterializationPlanError(
822
+ f"{path} carries identity-bearing field(s) {sorted(present)}; "
823
+ "gr2 MaterializationPlan operations must be identity-free"
824
+ )
825
+ for key, nested in value.items():
826
+ _reject_identity_fields_recursive(nested, path=f"{path}.{key}")
827
+ elif isinstance(value, list):
828
+ for i, item in enumerate(value):
829
+ _reject_identity_fields_recursive(item, path=f"{path}[{i}]")
830
+
831
+
832
+ def _validate_path_safe_token(value: object, *, field_name: str) -> str:
833
+ """An opaque, path-safe identifier: no separators, no traversal, no
834
+ identity semantics interpreted. plan_id and unit_key both end up embedded
835
+ in filesystem paths (receipt filenames), so they are validated as tokens
836
+ -- gr2 never derives identity from unit_key, it only checks its shape."""
837
+ if not isinstance(value, str) or not value:
838
+ raise MaterializationPlanError(f"{field_name} must be a non-empty string")
839
+ if "/" in value or "\\" in value or value in (".", "..") or "\x00" in value:
840
+ raise MaterializationPlanError(f"{field_name} must be a path-safe token, got {value!r}")
841
+ return value
842
+
843
+
844
+ def canonicalize_workspace_path(workspace_root: Path, relative: str, *, field_name: str) -> Path:
845
+ """MaterializationPlan v1 invariant #2: reject absolute paths, `~`,
846
+ backslashes, empty segments, `.` or `..` segments, NUL, any existing
847
+ symlink in the path prefix, and any resolved escape.
848
+
849
+ Segment checks run on the RAW string split on "/" -- Path() silently
850
+ normalizes single-dot segments away (Path("a/./b").parts == ("a","b")),
851
+ so parts-based scanning cannot see them.
852
+
853
+ The per-component symlink walk (lstat on each existing component,
854
+ including the last) is what a resolve()-based containment check
855
+ structurally cannot provide: if a directory in the prefix is itself a
856
+ symlink, BOTH the candidate and the root resolve through that same link,
857
+ so "resolved candidate is under resolved root" holds while the real bytes
858
+ live outside.
859
+
860
+ Returns the fully resolved canonical path -- used for filesystem access
861
+ AND for all comparison (collision detection), so two spellings of one
862
+ real path cannot both pass."""
863
+ if not relative or relative.startswith("/") or relative.startswith("~"):
864
+ raise MaterializationPlanError(f"{field_name} must be relative to the workspace root: {relative!r}")
865
+ if "\\" in relative or "\x00" in relative:
866
+ raise MaterializationPlanError(f"{field_name} must not contain backslashes or NUL: {relative!r}")
867
+ segments = relative.split("/")
868
+ if any(seg in ("", ".", "..") for seg in segments):
869
+ raise MaterializationPlanError(
870
+ f"{field_name} must not contain empty, '.', or '..' segments: {relative!r}"
871
+ )
872
+ walker = workspace_root
873
+ for seg in segments:
874
+ walker = walker / seg
875
+ if walker.is_symlink():
876
+ raise MaterializationPlanError(
877
+ f"{field_name} passes through a symlink at {walker} -- path prefixes must be "
878
+ "symlink-free (MaterializationPlan v1 invariant #2)"
879
+ )
880
+ workspace_resolved = workspace_root.resolve()
881
+ candidate = (workspace_root / relative).resolve()
882
+ if candidate != workspace_resolved and workspace_resolved not in candidate.parents:
883
+ raise MaterializationPlanError(f"{field_name} escapes the workspace root: {relative!r}")
884
+ return candidate
885
+
886
+
887
+ # Exact per-kind ALLOWLISTS. Any field not explicitly permitted for its kind
888
+ # is rejected by construction -- which is what proves identity-freedom, since
889
+ # a field nobody thought to blacklist (display_name, say) cannot exist at all.
890
+ _CLONE_FIELDS = frozenset({"kind", "repo_url", "dest_path", "branch", "reference_base"})
891
+ _VENV_FIELDS = frozenset({"kind", "dest_path", "engine", "python"})
892
+ _EDITABLE_INSTALL_FIELDS = frozenset({"kind", "venv_path", "source_path", "extras"})
893
+ _PROJECT_FILE_FIELDS = frozenset({"kind", "source_path", "dest_path", "source_sha256", "mode"})
894
+
895
+ _OPERATION_ALLOWED_FIELDS: dict[str, frozenset[str]] = {
896
+ "clone": _CLONE_FIELDS,
897
+ "venv": _VENV_FIELDS,
898
+ "editable_install": _EDITABLE_INSTALL_FIELDS,
899
+ "project_file": _PROJECT_FILE_FIELDS,
900
+ }
901
+
902
+ _PLAN_ALLOWED_TOP_LEVEL_FIELDS = frozenset(
903
+ {"schema_version", "plan_id", "unit_key", "workspace_spec_sha256", "operations"}
904
+ )
905
+
906
+
907
+ def _require_str(op: dict[str, object], key: str, prefix: str) -> str:
908
+ value = op.get(key)
909
+ if not isinstance(value, str) or not value:
910
+ raise MaterializationPlanError(f"{prefix}: {key} must be a non-empty string")
911
+ return value
912
+
913
+
914
+ def _validate_operation_shape(
915
+ op: dict[str, object], *, idx: int, workspace_root: Path
916
+ ) -> Path | None:
917
+ """Plan-level shape + path safety for one operation. Filesystem
918
+ PROVENANCE (is that cache a real bare repo with the right origin, is
919
+ that staged input a regular file hashing to source_sha256) belongs to
920
+ the handler that consumes it and lands with S4-B/C/D. The pinned schema
921
+ already constrains reference_base and source_path syntactically.
922
+
923
+ Returns the canonical dest_path for collision detection, or None."""
924
+ kind = op.get("kind")
925
+ prefix = f"operations[{idx}] (kind={kind!r})"
926
+ if kind not in _VALID_OPERATION_KINDS:
927
+ raise MaterializationPlanError(f"{prefix}: unknown operation kind")
928
+
929
+ unknown = op.keys() - _OPERATION_ALLOWED_FIELDS[kind]
930
+ if unknown:
931
+ raise MaterializationPlanError(f"{prefix}: unknown field(s) {sorted(unknown)}")
932
+
933
+ if kind == "clone":
934
+ _require_str(op, "repo_url", prefix)
935
+ _require_str(op, "branch", prefix)
936
+ dest_path = _require_str(op, "dest_path", prefix)
937
+ reference_base = op.get("reference_base")
938
+ if reference_base is not None:
939
+ if not isinstance(reference_base, str) or not reference_base:
940
+ raise MaterializationPlanError(f"{prefix}: reference_base must be a non-empty string")
941
+ canonicalize_workspace_path(
942
+ workspace_root, reference_base, field_name=f"{prefix}.reference_base"
943
+ )
944
+ return canonicalize_workspace_path(workspace_root, dest_path, field_name=f"{prefix}.dest_path")
945
+ if kind == "venv":
946
+ dest_path = _require_str(op, "dest_path", prefix)
947
+ # No defaults anywhere: the pinned schema requires engine and python
948
+ # explicitly, and defaulting here would re-open the coercion the
949
+ # contract closed.
950
+ if op.get("engine") != "uv":
951
+ raise MaterializationPlanError(f"{prefix}: engine must be 'uv', got {op.get('engine')!r}")
952
+ _require_str(op, "python", prefix)
953
+ return canonicalize_workspace_path(workspace_root, dest_path, field_name=f"{prefix}.dest_path")
954
+ if kind == "editable_install":
955
+ venv_path = _require_str(op, "venv_path", prefix)
956
+ source_path = _require_str(op, "source_path", prefix)
957
+ canonicalize_workspace_path(workspace_root, venv_path, field_name=f"{prefix}.venv_path")
958
+ canonicalize_workspace_path(workspace_root, source_path, field_name=f"{prefix}.source_path")
959
+ extras = op.get("extras")
960
+ if not isinstance(extras, list) or not all(isinstance(e, str) for e in extras):
961
+ raise MaterializationPlanError(f"{prefix}: extras must be a list of strings (required)")
962
+ return None
963
+ # project_file
964
+ source_path = _require_str(op, "source_path", prefix)
965
+ dest_path = _require_str(op, "dest_path", prefix)
966
+ _require_str(op, "source_sha256", prefix)
967
+ canonicalize_workspace_path(workspace_root, source_path, field_name=f"{prefix}.source_path")
968
+ if op.get("mode") != "copy":
969
+ raise MaterializationPlanError(f"{prefix}: mode must be 'copy' for v1, got {op.get('mode')!r}")
970
+ return canonicalize_workspace_path(workspace_root, dest_path, field_name=f"{prefix}.dest_path")
971
+
972
+
973
+ def validate_materialization_plan(workspace_root: Path, plan: dict[str, object]) -> ValidatedPlan:
974
+ """Validate a neutral MaterializationPlan against its pinned v1
975
+ contract. Raises MaterializationPlanError on the first violation.
976
+
977
+ Validate-before-touch: this runs to completion over the WHOLE plan
978
+ before any handler executes, so an invalid operation late in the list
979
+ cannot let an earlier one mutate state first.
980
+
981
+ Returns a ValidatedPlan capability. Publication requires one, so a
982
+ receipt cannot be written from a plan that never passed this function
983
+ (Atlas P1) -- validation becomes something the publisher HOLDS rather
984
+ than something a caller is trusted to have remembered to do.
985
+
986
+ The caller's object is snapshotted on entry and never consulted again.
987
+ Taking the snapshot FIRST (rather than copying at mint) also closes the
988
+ window where a concurrent mutation could land between the checks and the
989
+ capture, which would validate one graph and bind another.
990
+ """
991
+ try:
992
+ plan = copy.deepcopy(plan)
993
+ except Exception as exc: # pragma: no cover - defensive
994
+ raise MaterializationPlanError(
995
+ f"plan could not be snapshotted for validation: {exc}"
996
+ ) from exc
997
+
998
+ validator = _load_plan_validator()
999
+ schema_errors = sorted(validator.iter_errors(plan), key=lambda e: list(e.absolute_path))
1000
+ if schema_errors:
1001
+ first = schema_errors[0]
1002
+ location = "/".join(str(p) for p in first.absolute_path) or "<root>"
1003
+ raise MaterializationPlanError(
1004
+ f"plan rejected by pinned MaterializationPlan v1 schema at {location}: {first.message}"
1005
+ )
1006
+
1007
+ unknown_top_level = plan.keys() - _PLAN_ALLOWED_TOP_LEVEL_FIELDS
1008
+ if unknown_top_level:
1009
+ raise MaterializationPlanError(f"plan: unknown top-level field(s) {sorted(unknown_top_level)}")
1010
+
1011
+ if isinstance(plan.get("schema_version"), bool) or plan.get("schema_version") != 1:
1012
+ raise MaterializationPlanError(
1013
+ f"plan.schema_version must be exactly 1, got {plan.get('schema_version')!r}"
1014
+ )
1015
+
1016
+ _validate_path_safe_token(plan.get("plan_id"), field_name="plan_id")
1017
+ # One MaterializationPlan is scoped to one opaque unit and carries a
1018
+ # required top-level unit_key. gr2 validates its shape without deriving
1019
+ # identity from it.
1020
+ _validate_path_safe_token(plan.get("unit_key"), field_name="unit_key")
1021
+
1022
+ # MaterializationPlan v1 invariant #1: reopen the canonical WorkspaceSpec
1023
+ # bytes and verify their SHA-256. Carrying the field is not verifying it.
1024
+ workspace_spec_sha256 = str(plan["workspace_spec_sha256"])
1025
+ actual_spec_sha256 = hashlib.sha256(_read_canonical_workspace_spec_bytes(workspace_root)).hexdigest()
1026
+ if actual_spec_sha256 != workspace_spec_sha256:
1027
+ raise MaterializationPlanError(
1028
+ f"workspace_spec_sha256 mismatch: plan declares {workspace_spec_sha256}, "
1029
+ f"canonical WorkspaceSpec bytes hash to {actual_spec_sha256} -- the plan was "
1030
+ "compiled against a different workspace state (MaterializationPlan v1 invariant #1)"
1031
+ )
1032
+
1033
+ operations = plan["operations"]
1034
+
1035
+ # MaterializationPlan v1 invariant #3: compare destinations after normalization and
1036
+ # Unicode-aware case folding. Raw-string comparison lets "u1/.venv" and
1037
+ # "u1/./.venv" both through, and casefold (not lower) is required so
1038
+ # "straße"/"STRASSE" collide.
1039
+ seen_canonical_dests: dict[str, int] = {}
1040
+ for idx, op in enumerate(operations):
1041
+ if not isinstance(op, dict):
1042
+ raise MaterializationPlanError(f"operations[{idx}] must be an object, got {type(op).__name__}")
1043
+ _reject_identity_fields_recursive(op, path=f"operations[{idx}]")
1044
+ canonical_dest = _validate_operation_shape(op, idx=idx, workspace_root=workspace_root)
1045
+ if canonical_dest is not None:
1046
+ # NFC-normalize BEFORE casefolding (Sentinel finding 7):
1047
+ # "units/café/.venv" spelled NFC vs NFD are distinct Python
1048
+ # strings that casefold to distinct values, yet on a
1049
+ # normalization-insensitive filesystem they name ONE
1050
+ # destination. Normalization and case folding are separate
1051
+ # aliasing axes; collision detection has to close both.
1052
+ key = unicodedata.normalize("NFC", str(canonical_dest)).casefold()
1053
+ if key in seen_canonical_dests:
1054
+ raise MaterializationPlanError(
1055
+ f"operations[{idx}] dest_path collides (case-folded/normalized) with "
1056
+ f"operations[{seen_canonical_dests[key]}]: {canonical_dest}"
1057
+ )
1058
+ seen_canonical_dests[key] = idx
1059
+
1060
+ # Hash the validated bytes ONCE, here, while they are still exactly what
1061
+ # passed the checks above. Recomputing at publication time is what let a
1062
+ # receipt attest to a post-validation mutation.
1063
+ facts = {
1064
+ "plan_hash": compute_plan_hash(plan),
1065
+ "plan_id": str(plan["plan_id"]),
1066
+ "unit_key": str(plan["unit_key"]),
1067
+ "schema_version": int(plan["schema_version"]),
1068
+ "workspace_spec_sha256": workspace_spec_sha256,
1069
+ "operation_kinds": tuple(str(op["kind"]) for op in operations),
1070
+ }
1071
+ validated = ValidatedPlan(
1072
+ plan=_deep_freeze(plan),
1073
+ _seal=_capability_seal(**facts),
1074
+ **facts,
1075
+ )
1076
+ _MINTED_CAPABILITIES.add(validated)
1077
+ return validated
1078
+
1079
+
1080
+ _WORKSPACE_SPEC_RELATIVE = ".grip/workspace_spec.toml"
1081
+ _RECEIPT_DIR_RELATIVE = ".grip/state/materialization"
1082
+
1083
+
1084
+ def _read_canonical_workspace_spec_bytes(workspace_root: Path) -> bytes:
1085
+ """MaterializationPlan v1 invariant #2 applies to contract paths too, not only to
1086
+ operation paths (Atlas P2): the canonical WorkspaceSpec must be reached
1087
+ through a symlink-free prefix and be a regular non-symlink file.
1088
+
1089
+ Otherwise a symlink at .grip/workspace_spec.toml pointing outside the
1090
+ team root is accepted whenever its bytes happen to hash to the declared
1091
+ value -- the hash check confirms CONTENT, and says nothing about whether
1092
+ the file it read is inside the workspace at all."""
1093
+ spec_file = canonicalize_workspace_path(
1094
+ workspace_root, _WORKSPACE_SPEC_RELATIVE, field_name="workspace_spec_path"
1095
+ )
1096
+ if not spec_file.exists():
1097
+ raise MaterializationPlanError(
1098
+ "canonical WorkspaceSpec (.grip/workspace_spec.toml) not found -- "
1099
+ "workspace_spec_sha256 cannot be verified (MaterializationPlan v1 invariant #1)"
1100
+ )
1101
+ if not stat.S_ISREG(os.lstat(spec_file).st_mode):
1102
+ raise MaterializationPlanError(
1103
+ f"canonical WorkspaceSpec at {spec_file} is not a regular file "
1104
+ "(MaterializationPlan v1 invariant #2)"
1105
+ )
1106
+ return spec_file.read_bytes()
1107
+
1108
+
1109
+ def _canonical_receipt_dir(workspace_root: Path) -> Path:
1110
+ """The receipt directory must be a real in-root directory reached
1111
+ through a symlink-free prefix (Atlas P2): a symlinked
1112
+ .grip/state/materialization otherwise publishes the terminal receipt
1113
+ outside the team root entirely."""
1114
+ receipt_dir = canonicalize_workspace_path(
1115
+ workspace_root, _RECEIPT_DIR_RELATIVE, field_name="receipt_dir"
1116
+ )
1117
+ receipt_dir.mkdir(parents=True, exist_ok=True)
1118
+ if not stat.S_ISDIR(os.lstat(receipt_dir).st_mode):
1119
+ raise MaterializationPlanError(
1120
+ f"receipt directory {receipt_dir} is not a real directory "
1121
+ "(MaterializationPlan v1 invariant #2)"
1122
+ )
1123
+ return receipt_dir
1124
+
1125
+
1126
+ def compute_plan_hash(plan: dict[str, object]) -> str:
1127
+ """MaterializationPlan v1 invariant #9, the exact pinned canonical serialization: UTF-8
1128
+ JSON, keys sorted, no insignificant whitespace, non-ASCII unescaped.
1129
+ Default json.dumps separators and ensure_ascii=True produce different
1130
+ bytes and therefore a non-conformant hash. Public so callers and tests
1131
+ recompute it independently rather than trusting a receipt's own value.
1132
+
1133
+ `default` is a TYPE adapter, not a change to the recipe: it fires only
1134
+ for objects json cannot serialize natively, so canonical bytes for a
1135
+ plain JSON graph are byte-identical either way. It exists so freezing a
1136
+ validated plan does not turn this public helper into a trap for the
1137
+ handlers that will hold one in B/C/D."""
1138
+
1139
+ def _unfreeze(obj: object) -> object:
1140
+ if isinstance(obj, MappingProxyType):
1141
+ return dict(obj)
1142
+ raise TypeError(f"cannot canonicalize {type(obj).__name__} in a MaterializationPlan")
1143
+
1144
+ return hashlib.sha256(
1145
+ json.dumps(
1146
+ plan, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=_unfreeze
1147
+ ).encode("utf-8")
1148
+ ).hexdigest()
1149
+
1150
+
1151
+ def materialization_receipt_path(workspace_root: Path, plan_id: str) -> Path:
1152
+ return workspace_root / ".grip" / "state" / "materialization" / f"{plan_id}.json"
1153
+
1154
+
1155
+ def _screen_receipt_evidence(binding: _ConsumptionBinding) -> None:
1156
+ """The terminal receipt must be bound to the
1157
+ plan it claims to acknowledge, and its evidence must be as neutral as
1158
+ the plan.
1159
+
1160
+ The plan's own operations are protected by a CLOSED schema whose
1161
+ per-kind allowlists permit no nested object carrier -- so recursive
1162
+ identity rejection there has little left to find. The result graph is
1163
+ the opposite: it is open, it is what gets persisted, and it was
1164
+ previously copied verbatim. That makes it the actual smuggling boundary,
1165
+ which is why the same rejection discipline is applied here rather than
1166
+ only upstream.
1167
+
1168
+ Screens the CONSUMPTION BINDING, never the caller's object: the evidence
1169
+ was walked twice before -- once here, once by json serialization -- so a
1170
+ list whose __iter__ returned different contents per call screened clean
1171
+ and then persisted `secret` into the receipt anyway. Screening a value
1172
+ that is not the one persisted screens nothing."""
1173
+ op_results = binding.evidence
1174
+ if len(op_results) != len(binding.operation_kinds):
1175
+ raise MaterializationPlanError(
1176
+ f"receipt evidence has {len(op_results)} result(s) but the validated plan has "
1177
+ f"{len(binding.operation_kinds)} operation(s) -- a receipt cannot claim "
1178
+ "MATERIALIZED without evidence for every operation"
1179
+ )
1180
+ for idx, (result, expected_kind) in enumerate(zip(op_results, binding.operation_kinds)):
1181
+ if not isinstance(result, dict):
1182
+ raise MaterializationPlanError(
1183
+ f"receipt evidence[{idx}] must be an object, got {type(result).__name__}"
1184
+ )
1185
+ if result.get("kind") != expected_kind:
1186
+ raise MaterializationPlanError(
1187
+ f"receipt evidence[{idx}] is kind {result.get('kind')!r} but the validated plan's "
1188
+ f"operation {idx} is {expected_kind!r} -- evidence must correspond to its operation, "
1189
+ "in order"
1190
+ )
1191
+ _reject_identity_fields_recursive(result, path=f"receipt.operations[{idx}]")
1192
+
1193
+
1194
+ def write_materialization_receipt(
1195
+ workspace_root: Path,
1196
+ validated: ValidatedPlan,
1197
+ op_results: list[dict[str, object]],
1198
+ ) -> Path:
1199
+ """Publish the terminal neutral receipt for a VALIDATED plan.
1200
+
1201
+ Takes a ValidatedPlan capability rather than a raw dict (Atlas P1): the
1202
+ writer previously accepted the live plan and an arbitrary result list,
1203
+ so a schema-invalid plan_id could escape the receipt directory and an
1204
+ unscreened result graph could be persisted verbatim. Holding the
1205
+ capability is the proof that the contract already ran.
1206
+
1207
+ MaterializationPlan v1 invariant #10 -- publication order is exact and every step is
1208
+ load-bearing: same-directory temp -> write+flush -> fsync(temp file) ->
1209
+ atomic replace -> fsync(parent directory). Rename success alone is not
1210
+ durable acknowledgement; on power loss the rename can survive while the
1211
+ bytes do not. Callers performing destructive cleanup on the strength of
1212
+ a receipt must do it only after this returns.
1213
+
1214
+ The temp file is created O_EXCL|O_NOFOLLOW (Atlas P2): its name is
1215
+ predictable, so a plain open() would happily follow a pre-created
1216
+ symlink, overwrite whatever it points at, and then publish that symlink
1217
+ as the final receipt."""
1218
+ if not isinstance(validated, ValidatedPlan):
1219
+ raise MaterializationPlanError(
1220
+ "write_materialization_receipt requires a ValidatedPlan from "
1221
+ "validate_materialization_plan, not a raw plan"
1222
+ )
1223
+ # ONE verified consumption. Everything below reads `binding` and nothing
1224
+ # reads `validated` again -- verifying and then re-reading the live shell
1225
+ # is precisely the check/use window Atlas's callback drove through.
1226
+ binding = validated.consume(op_results)
1227
+ _screen_receipt_evidence(binding)
1228
+
1229
+ receipt = {
1230
+ "plan_id": binding.plan_id,
1231
+ "unit_key": binding.unit_key,
1232
+ # Derived from the sealed snapshot at the consumption instant, NOT
1233
+ # recomputed from a graph that may have moved since.
1234
+ "plan_hash": binding.plan_hash,
1235
+ "schema_version": binding.schema_version,
1236
+ "workspace_spec_sha256": binding.workspace_spec_sha256,
1237
+ # §12.1 structural stage: MATERIALIZED is the terminal state OSS gr2
1238
+ # can honestly claim on its own.
1239
+ "stage": "MATERIALIZED",
1240
+ "applied_at": datetime.now(UTC).isoformat(),
1241
+ "operations": list(binding.evidence),
1242
+ }
1243
+
1244
+ receipt_dir = _canonical_receipt_dir(workspace_root)
1245
+ # Both filenames come from the binding. The temp name carries the same
1246
+ # identity, so leaving it on a live read would only move the window
1247
+ # rather than close it.
1248
+ receipt_path = receipt_dir / f"{binding.plan_id}.json"
1249
+ tmp_path = receipt_dir / f"{binding.plan_id}.json.tmp-{os.getpid()}"
1250
+
1251
+ payload = (json.dumps(receipt, indent=2) + "\n").encode("utf-8")
1252
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
1253
+ try:
1254
+ with os.fdopen(fd, "wb") as fh:
1255
+ fh.write(payload)
1256
+ fh.flush()
1257
+ os.fsync(fh.fileno())
1258
+ except BaseException:
1259
+ tmp_path.unlink(missing_ok=True)
1260
+ raise
1261
+ try:
1262
+ os.replace(tmp_path, receipt_path)
1263
+ except BaseException:
1264
+ # A failed replace leaves the temp behind otherwise -- found by this
1265
+ # closure's own residue test rather than reasoned about.
1266
+ tmp_path.unlink(missing_ok=True)
1267
+ raise
1268
+
1269
+ # Sentinel finding 3: durability is a FAILURE-PATH contract, not only an
1270
+ # ordering one. If the parent-directory fsync fails, the rename may not
1271
+ # survive a crash -- yet the receipt is already visible at its published
1272
+ # path, so a caller that treats "the writer returned" or "a receipt
1273
+ # exists" as durable acknowledgement would proceed to destructive
1274
+ # cleanup on the strength of a receipt that could vanish. A publication
1275
+ # that cannot be made durable must not remain published.
1276
+ try:
1277
+ dir_fd = os.open(receipt_dir, os.O_RDONLY)
1278
+ try:
1279
+ os.fsync(dir_fd)
1280
+ finally:
1281
+ os.close(dir_fd)
1282
+ except BaseException:
1283
+ receipt_path.unlink(missing_ok=True)
1284
+ raise
1285
+ return receipt_path