@mriekki/project-forge 4.20.0 → 4.22.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.20.0",
4
+ "version": "4.22.0",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -21,7 +21,7 @@
21
21
  "canonicalDescriptor": "distributions/greenfield-workspace.json"
22
22
  },
23
23
  "deployment": {
24
- "revision": "977ada7310f649adea34059381339012a0c0daaf",
24
+ "revision": "2258794181483747ed11a2c3c3226d6e66e1b9bb",
25
25
  "distribution": "greenfield-project",
26
26
  "descriptor": "assets/deployment/distribution.json",
27
27
  "sourceRoot": "assets/generated/deployment",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.20.0",
4
+ "version": "4.22.0",
5
5
  "components": {
6
6
  "core": {
7
7
  "revision": "4c74c1801b82d3aa438447d392024d32ca5697b0",
@@ -361,7 +361,7 @@
361
361
  ]
362
362
  },
363
363
  "deployment": {
364
- "revision": "977ada7310f649adea34059381339012a0c0daaf",
364
+ "revision": "2258794181483747ed11a2c3c3226d6e66e1b9bb",
365
365
  "distribution": "greenfield-project",
366
366
  "files": [
367
367
  {
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "source": "src/project_forge_deployment/app_cli.py",
394
394
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py",
395
- "sha256": "f27704c80b1eff45888bf56745bcf8755f2fad88851147600af7056953757825"
395
+ "sha256": "b44ec913fd1080ff66bff6643d2d9fad17262725c0f35557c56f52c86090c060"
396
396
  },
397
397
  {
398
398
  "source": "src/project_forge_deployment/app_deployment.py",
@@ -402,12 +402,12 @@
402
402
  {
403
403
  "source": "src/project_forge_deployment/app_runtime.py",
404
404
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime.py",
405
- "sha256": "73736aada7c3db0e2a7c74a3e88990316b89e6c34a71b4997cdc4c79c7e31708"
405
+ "sha256": "6f4ae7f663dc7a4e51225c1420f7aa6be5d2b77445b792f9e4495ba3eeb059ad"
406
406
  },
407
407
  {
408
408
  "source": "src/project_forge_deployment/app_runtime_remote.py",
409
409
  "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime_remote.py",
410
- "sha256": "791cc7592cff93555f7b6facdd31fbe58de5e711732d9dfcb9321500a0d79cdf"
410
+ "sha256": "5b6ddffcc1105d3a401ae17456cdfdb279c3005bfcb6c81bbd1eabc27479de05"
411
411
  },
412
412
  {
413
413
  "source": "src/project_forge_deployment/app_runtime_workstation.py",
@@ -437,12 +437,12 @@
437
437
  {
438
438
  "source": "src/project_forge_deployment/cloudflare_ingress.py",
439
439
  "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress.py",
440
- "sha256": "d07c79b7f9f6c9a0f9b417fd0a1d4502101075b128a12d23b2791c31e03d4f01"
440
+ "sha256": "31541e115f474fd3bd2a426eac1ca1beea429633c6ecc48afeace025a85054a9"
441
441
  },
442
442
  {
443
443
  "source": "src/project_forge_deployment/cloudflare_ingress_remote.py",
444
444
  "destination": "tools/project-forge-deployment/project_forge_deployment/cloudflare_ingress_remote.py",
445
- "sha256": "73d9f68cbece33155ccac8ce2e24da8f0ec6698a7b66fba18cb9bf38734ea686"
445
+ "sha256": "19de0d8c04b7ad0006b8bc341eabd52a5eaa50c0e0bacdc9edb856de101dd259"
446
446
  },
447
447
  {
448
448
  "source": "src/project_forge_deployment/cloudflare_route_apply.py",
@@ -502,7 +502,7 @@
502
502
  {
503
503
  "source": "src/project_forge_deployment/ingress_cli.py",
504
504
  "destination": "tools/project-forge-deployment/project_forge_deployment/ingress_cli.py",
505
- "sha256": "973b89daa7e57d925f0be56fbfc0dc4c6f79ab082036c7fb26462691f2f16d02"
505
+ "sha256": "069a7e5a4c7921448e6965ebff2df8b38a9aba77aaff1154a54969ab0e234324"
506
506
  },
507
507
  {
508
508
  "source": "src/project_forge_deployment/keys.py",
@@ -647,7 +647,7 @@
647
647
  {
648
648
  "source": "docs/CLOUDFLARE_INGRESS.md",
649
649
  "destination": "docs/project-forge/CLOUDFLARE_INGRESS.md",
650
- "sha256": "b47bfb1ef1dcd7be3c501b88add0dea675907ce3f9e65cf7852c5ffddbe0ce46"
650
+ "sha256": "396327ae00bf4a54eeaea6eec8c07a17af6084eed5c4d0be97b55b29bd0160ff"
651
651
  },
652
652
  {
653
653
  "source": "docs/DEVELOPER_ENVIRONMENTS.md",
@@ -200,11 +200,45 @@ without a zone or public hostname. Once a domain is registered and active in Clo
200
200
 
201
201
  The shared Cloudflare zone is environment infrastructure and should not be copied into every Project as a credential-like default. The exact application hostname remains Project/environment state.
202
202
 
203
- ## Experimental authenticated public-origin mode (new scopes only)
203
+ ## Experimental authenticated public-origin mode
204
204
 
205
205
  For an isolated test scope, `ingress cloudflare configure` supports `--authenticated-origin`. This explicit opt-in provisions a private Caddy Unix listener and mounts its socket directory only in the pinned cloudflared connector. Public hostname rules use `unix:/run/project-forge-origin/origin.sock` and the public hostname as Host; the LAN TCP listener and the existing default mode remain unchanged. The remote adapter derives each authenticated route's upstream from its still-owned LAN registry entry and strips/replaces forwarding headers before proxying.
206
206
 
207
- **Not a supported Production setting yet:** origin mount possession is not end-user login. Real pinned cloudflared edge-to-app forwarding, canonical client-IP semantics, app proxy-peer trust, socket replacement, and rollback still need live proof. Do not grant the socket mount to unrelated containers or treat forwarded IP headers as an authorization credential. An installed ingress scope cannot switch modes in place; use a new dedicated scope and the normal host/route write gates. See the canonical Kit's `docs/EXPERIMENTAL_AUTHENTICATED_ORIGIN.md` and issue #78 for the detailed evidence boundary.
207
+ **Not a supported Production setting yet:** origin mount possession is not end-user login. Real pinned cloudflared edge-to-app forwarding, canonical client-IP semantics, app proxy-peer trust, socket replacement, and rollback still need live proof. Do not grant the socket mount to unrelated containers or treat forwarded IP headers as an authorization credential.
208
+
209
+ An existing legacy scope can be migrated without replacing its tunnel, public routes, DNS/CNAME records, or unrelated tunnel rules. The operation has its own read-only plan, exact interactive authorization, and guarded apply:
210
+
211
+ ```text
212
+ project-forge-deploy ingress cloudflare migrate-authenticated-origin <target> \
213
+ --scope production \
214
+ --zone example.com
215
+ ```
216
+
217
+ Before planning, the operator must stop all Cloudflare dashboard/API tunnel-configuration writers for this scope. The CLI asks for an exact scope-bound freeze-active acknowledgment before making plan reads, and the programmatic planning API requires the corresponding `change_freeze_active_acknowledgement`, for example:
218
+
219
+ ```text
220
+ [CLOUDFLARE EXTERNAL CHANGE FREEZE] ACTIVE scope=production
221
+ ```
222
+
223
+ The freeze must remain in effect through apply, verification, and any rollback or recovery. It is an external operational prerequisite: the Deployment Kit cannot enforce it, identify all external writers, or prove that they are stopped. Cloudflare's full-configuration PUT has no compare-and-swap; pre-write snapshots and post-write checks do not prevent an external write from racing between a read and PUT. Do not start the operation unless this freeze can be maintained.
224
+
225
+ The plan requires every host-registered route to match one exact Project Forge legacy provider rule and one exact owned DNS record. Conflicting/unowned rules for managed hostnames, missing or duplicate routes, stale host/provider/DNS snapshots, or incomplete host migration evidence stop the operation. Other provider rules remain unchanged; no DNS write or tunnel creation occurs. Apply holds the existing per-scope host lock, prepares the connector-only socket and sanitized Caddy routes first, then changes only the exact managed Cloudflare rules. It rechecks ownership immediately before each phase and verifies the unchanged tunnel, route and DNS identities plus the socket/header contract afterward. These checks are defense in depth, not enforcement of the external freeze. There is no generic `--yes` bypass; the CLI first requires:
226
+
227
+ ```text
228
+ [CLOUDFLARE AUTHENTICATED ORIGIN MIGRATION]
229
+ ```
230
+
231
+ After showing the plan, the CLI separately requires the migration authorization and an exact freeze acknowledgment bound to the reviewed scope and snapshot context, for example:
232
+
233
+ ```text
234
+ [CLOUDFLARE EXTERNAL CHANGE FREEZE] scope=production context=<plan-specific digest>
235
+ ```
236
+
237
+ The programmatic apply API likewise requires `change_freeze_acknowledgement`; migration authorization alone is insufficient. The bound acknowledgment prevents accidental reuse for a different scope/plan, but is an operator assertion only—not a lock on Cloudflare. If the provider update is confirmed unchanged, a newly prepared host phase is rolled back from a protected host receipt. If provider state is uncertain or differs from both reviewed states, the host remains authenticated with its receipt retained; maintain the external freeze and do not hand-edit or rerun blindly. Re-plan/resume or roll back only after inspecting current state; keep the freeze until recovery is complete. This migration still does not establish an application identity/login flow or replace disposable application/database state.
238
+
239
+ Final migration output labels its evidence **structural-only**: it checks socket metadata, connector mount configuration, Caddy configuration, and the generated sanitized route files. It does not prove that the running pinned cloudflared connector can forward an HTTP request over that socket or that an application receives the intended headers. Live socket forwarding and end-to-end readiness remain unproven.
240
+
241
+ See `docs/EXPERIMENTAL_AUTHENTICATED_ORIGIN.md` and issue #78 for the detailed evidence boundary. For a new isolated scope, `configure --authenticated-origin` and the normal route gates remain unchanged.
208
242
 
209
243
  ## Current cloudflared runtime
210
244
 
@@ -49,6 +49,16 @@ def _parse_env(items: list[str]) -> list[tuple[str, str]]:
49
49
  return result
50
50
 
51
51
 
52
+ def _parse_materials(items: list[str]) -> list[tuple[str, str]]:
53
+ values: list[tuple[str, str]] = []
54
+ for item in items:
55
+ if "=" not in item:
56
+ raise DeploymentKitError("--read-only-material requires NAME=/absolute/container/directory.")
57
+ name, target = item.split("=", 1)
58
+ values.append((name, target))
59
+ return values
60
+
61
+
52
62
  def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
53
63
  if args.yes:
54
64
  return
@@ -91,6 +101,13 @@ def _add_runtime_contract(parser: argparse.ArgumentParser) -> None:
91
101
  parser.add_argument("--health-path", required=True)
92
102
  parser.add_argument("--run-as", required=True)
93
103
  parser.add_argument("--data-container-path")
104
+ parser.add_argument(
105
+ "--read-only-material",
106
+ action="append",
107
+ default=[],
108
+ metavar="NAME=/container/directory",
109
+ help="Opt-in root-owned remote material directory; source derived from Project/environment/name",
110
+ )
94
111
  parser.add_argument(
95
112
  "--env",
96
113
  action="append",
@@ -185,6 +202,7 @@ def main(argv: list[str] | None = None) -> int:
185
202
  persistence_mode=args.persistence_mode,
186
203
  data_container_path=args.data_container_path,
187
204
  environment_variables=_parse_env(args.env),
205
+ read_only_materials=_parse_materials(args.read_only_material),
188
206
  )
189
207
  assert_app_spec_matches_persistence(args.name, spec)
190
208
 
@@ -19,6 +19,8 @@ from .app_runtime_remote import REMOTE_SCRIPT
19
19
 
20
20
  APP_ROOT = "/srv/project-forge-apps"
21
21
  APP_SCHEMA_VERSION = 1
22
+ # Operator-provisioned remote sources: never workstation paths or auto-created directories.
23
+ REMOTE_MATERIAL_ROOT = "/srv/project-forge-material"
22
24
  APP_DEPLOY_WRITE_PHRASE = "[APP DEPLOY WRITE]"
23
25
  PRODUCTION_APP_DEPLOY_WRITE_PHRASE = "[PRODUCTION APP DEPLOY]"
24
26
 
@@ -84,6 +86,67 @@ def _container_path(value: str | None) -> str | None:
84
86
  return value.rstrip("/") or "/"
85
87
 
86
88
 
89
+
90
+ def _normalize_read_only_materials(
91
+ materials: Iterable[tuple[str, str]] | None,
92
+ *,
93
+ data_path: str | None,
94
+ ) -> list[dict[str, str]]:
95
+ if materials is None:
96
+ return []
97
+ result: list[dict[str, str]] = []
98
+ names: set[str] = set()
99
+ paths: list[str] = []
100
+ for name, target in materials:
101
+ if len(result) >= 8:
102
+ raise DeploymentKitError("Remote read-only material mount count must not exceed eight.")
103
+ name = _slug(name, "read-only material name")
104
+ if name in names:
105
+ raise DeploymentKitError(f"Duplicate read-only material name: {name}")
106
+ if not isinstance(target, str) or _CONTAINER_PATH_RE.fullmatch(target) is None:
107
+ raise DeploymentKitError("Read-only material target must be a safe absolute container directory.")
108
+ parts = target.split("/")[1:]
109
+ if not parts or any(part in {"", ".", ".."} for part in parts):
110
+ raise DeploymentKitError("Read-only material target must be normalized and cannot be root.")
111
+ if parts[0] in {"proc", "sys", "dev", "run"} or parts[:2] == ["var", "run"]:
112
+ raise DeploymentKitError("Read-only material cannot mask a container system runtime directory.")
113
+ for other in paths + ([data_path] if data_path else []):
114
+ if target == other or target.startswith(other.rstrip("/") + "/") or other.startswith(target + "/"):
115
+ raise DeploymentKitError("Read-only material target overlaps another mount.")
116
+ names.add(name)
117
+ paths.append(target)
118
+ result.append({"name": name, "containerPath": target})
119
+ return sorted(result, key=lambda item: item["name"])
120
+
121
+
122
+ def _material_evidence_for_spec(name: str, spec: dict[str, Any]) -> dict[str, str] | None:
123
+ materials = spec.get("readOnlyMaterials")
124
+ if not materials:
125
+ return None
126
+ profile = load_profile(name)
127
+ evidence = verify_access(profile)
128
+ output = _run_remote(profile, evidence["address"], "material-inspect", spec=spec)
129
+ observed: dict[str, str] = {}
130
+ for line in output.splitlines():
131
+ parts = line.split("\t")
132
+ if len(parts) != 3 or parts[0] != "MATERIAL":
133
+ raise DeploymentKitError("Malformed remote material source evidence.")
134
+ _, material_name, identity = parts
135
+ if not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", material_name) or not re.fullmatch(r"[0-9]+:[0-9]+", identity):
136
+ raise DeploymentKitError("Malformed remote material source identity.")
137
+ if material_name in observed:
138
+ raise DeploymentKitError("Duplicate remote material source evidence.")
139
+ observed[material_name] = identity
140
+ if set(observed) != {item["name"] for item in materials}:
141
+ raise DeploymentKitError("Remote material inspection omitted or added a source.")
142
+ return dict(sorted(observed.items()))
143
+
144
+
145
+ def _verify_material_evidence(name: str, plan: dict[str, Any]) -> None:
146
+ if plan["spec"].get("readOnlyMaterials"):
147
+ if _material_evidence_for_spec(name, plan["spec"]) != plan["readOnlyMaterialEvidence"]:
148
+ raise DeploymentKitError("Remote material directory identity changed after planning; review a new plan.")
149
+
87
150
  def _normalize_environment_variables(items: dict[str, str] | Iterable[tuple[str, str]]) -> dict[str, str]:
88
151
  pairs = items.items() if isinstance(items, dict) else items
89
152
  result: dict[str, str] = {}
@@ -111,6 +174,7 @@ def normalize_app_spec(
111
174
  persistence_mode: str,
112
175
  data_container_path: str | None,
113
176
  environment_variables: dict[str, str] | Iterable[tuple[str, str]],
177
+ read_only_materials: Iterable[tuple[str, str]] | None = None,
114
178
  ) -> dict[str, Any]:
115
179
  if persistence_mode not in {"none", "directory"}:
116
180
  raise DeploymentKitError("persistenceMode must be 'none' or 'directory'.")
@@ -135,6 +199,9 @@ def normalize_app_spec(
135
199
  },
136
200
  "environmentVariables": _normalize_environment_variables(environment_variables),
137
201
  }
202
+ materials = _normalize_read_only_materials(read_only_materials, data_path=data_path)
203
+ if materials:
204
+ spec["readOnlyMaterials"] = materials
138
205
  spec["specSha256"] = _spec_sha(spec)
139
206
  return spec
140
207
 
@@ -150,8 +217,13 @@ def _b64(value: str) -> str:
150
217
  return base64.b64encode(value.encode("utf-8")).decode("ascii")
151
218
 
152
219
 
153
- def build_remote_script(spec: dict[str, Any] | None = None) -> str:
220
+ def build_remote_script(
221
+ spec: dict[str, Any] | None = None, *, expected_materials: dict[str, str] | None = None,
222
+ ) -> str:
154
223
  env_keys: list[str] = []
224
+ material_names: list[str] = []
225
+ material_targets: list[str] = []
226
+ material_expected: list[str] = []
155
227
  env_values: list[str] = []
156
228
  desired = {
157
229
  "ownerRepo": "",
@@ -183,9 +255,14 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
183
255
  for key, value in spec["environmentVariables"].items():
184
256
  env_keys.append(_b64(key))
185
257
  env_values.append(_b64(value))
258
+ for item in spec.get("readOnlyMaterials", []):
259
+ material_names.append(_b64(item["name"]))
260
+ material_targets.append(_b64(item["containerPath"]))
261
+ material_expected.append((expected_materials or {}).get(item["name"], ""))
186
262
 
187
263
  replacements = {
188
264
  "__APP_ROOT__": APP_ROOT,
265
+ "__MATERIAL_ROOT__": REMOTE_MATERIAL_ROOT,
189
266
  "__APP_SCHEMA_VERSION__": str(APP_SCHEMA_VERSION),
190
267
  "__NETWORK_NAME__": NETWORK_NAME,
191
268
  "__CADDY_CONTAINER__": CADDY_CONTAINER,
@@ -203,6 +280,9 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
203
280
  "__D_SPEC_SHA__": desired["specSha256"],
204
281
  "__D_ENV_KEYS__": " ".join(f"'{item}'" for item in env_keys),
205
282
  "__D_ENV_VALUES__": " ".join(f"'{item}'" for item in env_values),
283
+ "__D_MATERIAL_NAMES__": " ".join(shlex.quote(x) for x in material_names),
284
+ "__D_MATERIAL_TARGETS__": " ".join(shlex.quote(x) for x in material_targets),
285
+ "__D_MATERIAL_EXPECTED__": " ".join(shlex.quote(x) for x in material_expected),
206
286
  }
207
287
  script = REMOTE_SCRIPT
208
288
  for placeholder, value in replacements.items():
@@ -224,7 +304,7 @@ def _parse_inventory(output: str) -> dict[str, Any]:
224
304
  raise DeploymentKitError("Malformed or duplicate application inventory summary.")
225
305
  summary[parts[1]] = parts[2]
226
306
  continue
227
- if parts[0] != "APP" or len(parts) != 15:
307
+ if parts[0] != "APP" or len(parts) not in (15, 17):
228
308
  raise DeploymentKitError("Malformed application inventory record.")
229
309
  try:
230
310
  port = int(parts[7])
@@ -258,6 +338,29 @@ def _parse_inventory(output: str) -> dict[str, Any]:
258
338
  "connectedToSharedNetwork": parts[13] == "true",
259
339
  "httpHealthy": parts[14] == "true",
260
340
  }
341
+ if len(parts) == 17:
342
+ if parts[15] not in {"true", "false"} or not parts[16]:
343
+ raise DeploymentKitError("Malformed remote material mount readiness.")
344
+ materials: list[dict[str, str]] = []
345
+ for encoded in parts[16].split(";"):
346
+ pieces = encoded.split("@")
347
+ if len(pieces) != 3 or re.fullmatch(r"[0-9]+:[0-9]+", pieces[2]) is None:
348
+ raise DeploymentKitError("Malformed remote material inventory identity.")
349
+ materials.append({
350
+ "name": pieces[0],
351
+ "containerPath": pieces[1],
352
+ "sourceIdentity": pieces[2],
353
+ })
354
+ normalized = _normalize_read_only_materials(
355
+ [(item["name"], item["containerPath"]) for item in materials],
356
+ data_path=None,
357
+ )
358
+ if [(item["name"], item["containerPath"]) for item in normalized] != [
359
+ (item["name"], item["containerPath"]) for item in materials
360
+ ]:
361
+ raise DeploymentKitError("Remote material inventory is unordered or conflicting.")
362
+ app["readOnlyMaterials"] = materials
363
+ app["materialMountsVerified"] = parts[15] == "true"
261
364
  apps.append(app)
262
365
  if set(summary) != {"appCount"}:
263
366
  raise DeploymentKitError("Application inventory omitted appCount.")
@@ -280,8 +383,9 @@ def _run_remote(
280
383
  *,
281
384
  spec: dict[str, Any] | None = None,
282
385
  expected_spec_sha: str = "",
386
+ expected_materials: dict[str, str] | None = None,
283
387
  ) -> str:
284
- if action not in {"inventory", "deploy"}:
388
+ if action not in {"inventory", "deploy", "material-inspect"}:
285
389
  raise DeploymentKitError(f"Unsupported application-runtime action: {action}")
286
390
  args = [action]
287
391
  if action == "deploy":
@@ -293,7 +397,7 @@ def _run_remote(
293
397
  )
294
398
  completed = subprocess.run(
295
399
  command,
296
- input=build_remote_script(spec).encode("utf-8"),
400
+ input=build_remote_script(spec, expected_materials=expected_materials).encode("utf-8"),
297
401
  check=False,
298
402
  capture_output=True,
299
403
  )
@@ -360,9 +464,10 @@ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
360
464
  and existing["containerRunning"]
361
465
  and existing["connectedToSharedNetwork"]
362
466
  and existing["httpHealthy"]
467
+ and existing.get("materialMountsVerified", True)
363
468
  ):
364
469
  raise DeploymentKitError("The exact application runtime spec is already deployed and healthy.")
365
- return {
470
+ plan = {
366
471
  "targetName": name,
367
472
  "action": "deploy",
368
473
  "environment": spec["environment"],
@@ -385,6 +490,9 @@ def plan_app_deploy(name: str, spec: dict[str, Any]) -> dict[str, Any]:
385
490
  "expectedSpecSha256": existing["specSha256"] if existing is not None else None,
386
491
  "spec": spec,
387
492
  }
493
+ if spec.get("readOnlyMaterials"):
494
+ plan["readOnlyMaterialEvidence"] = _material_evidence_for_spec(name, spec)
495
+ return plan
388
496
 
389
497
 
390
498
  def deploy_write_phrase(plan: dict[str, Any]) -> str:
@@ -428,7 +536,7 @@ def render_locked_app_deploy_stage(
428
536
  "expectedSpecSha256",
429
537
  "spec",
430
538
  }
431
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
539
+ if not isinstance(plan, dict) or set(plan) != required | ({"readOnlyMaterialEvidence"} if plan.get("spec", {}).get("readOnlyMaterials") else set()) or plan["action"] != "deploy":
432
540
  raise DeploymentKitError("Malformed application deployment plan.")
433
541
  if (plan["changeType"] != "update" or plan["retainsPreviousRelease"] is not True
434
542
  or not isinstance(plan["expectedSpecSha256"], str)
@@ -456,7 +564,8 @@ def render_locked_app_deploy_stage(
456
564
  )
457
565
  profile = load_profile(plan["targetName"])
458
566
  evidence = verify_access(profile)
459
- script = build_remote_script(plan["spec"])
567
+ _verify_material_evidence(plan["targetName"], plan)
568
+ script = build_remote_script(plan["spec"], expected_materials=plan.get("readOnlyMaterialEvidence"))
460
569
  delimiter = "PROJECT_FORGE_APP_DEPLOY_STAGE_" + hashlib.sha256(script.encode()).hexdigest()[:24]
461
570
  if delimiter in script:
462
571
  raise DeploymentKitError("Unsafe application deployment script delimiter collision.")
@@ -495,7 +604,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
495
604
  "expectedSpecSha256",
496
605
  "spec",
497
606
  }
498
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "deploy":
607
+ if not isinstance(plan, dict) or set(plan) != required | ({"readOnlyMaterialEvidence"} if plan.get("spec", {}).get("readOnlyMaterials") else set()) or plan["action"] != "deploy":
499
608
  raise DeploymentKitError("Malformed application deployment plan.")
500
609
 
501
610
  current = app_inventory(plan["targetName"])
@@ -507,6 +616,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
507
616
  if platform["state"] != "ready":
508
617
  raise DeploymentKitError("Shared host platform is no longer ready; application deployment is blocked.")
509
618
 
619
+ _verify_material_evidence(plan["targetName"], plan)
510
620
  profile = load_profile(plan["targetName"])
511
621
  evidence = verify_access(profile)
512
622
  _run_remote(
@@ -515,6 +625,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
515
625
  "deploy",
516
626
  spec=plan["spec"],
517
627
  expected_spec_sha=plan["expectedSpecSha256"] or "",
628
+ expected_materials=plan.get("readOnlyMaterialEvidence"),
518
629
  )
519
630
  final = app_inventory(plan["targetName"])
520
631
  deployed = _find_app(final, plan["appSlug"], plan["environment"])
@@ -526,6 +637,7 @@ def apply_app_deploy(plan: dict[str, Any]) -> dict[str, Any]:
526
637
  deployed["containerRunning"]
527
638
  and deployed["connectedToSharedNetwork"]
528
639
  and deployed["httpHealthy"]
640
+ and deployed.get("materialMountsVerified", True)
529
641
  ):
530
642
  raise DeploymentKitError("Application deployment completed but runtime readiness is not healthy.")
531
643
  final["mutated"] = True