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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- 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
|