@mriekki/project-forge 4.4.0 → 4.4.2

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.
package/README.md CHANGED
@@ -126,6 +126,15 @@ npx @mriekki/project-forge@latest check
126
126
 
127
127
  Managed updates verify baselines and refuse unsafe drift or conflicts before modifying Project Forge-managed files.
128
128
 
129
+ ## Maintainer system reference
130
+
131
+ For cross-repository architecture, ownership, lifecycle, provenance, deployment, support-evidence, and governance questions, start with:
132
+
133
+ - [`docs/system/PROJECT_FORGE_SYSTEM_REQUIREMENTS.md`](docs/system/PROJECT_FORGE_SYSTEM_REQUIREMENTS.md) — normative cross-repository invariants;
134
+ - [`docs/system/PROJECT_FORGE_SYSTEM_DESIGN.md`](docs/system/PROJECT_FORGE_SYSTEM_DESIGN.md) — explanatory system map and pointers to detailed owning-repository contracts.
135
+
136
+ The system reference deliberately does not duplicate current component SHAs, package/catalog versions, support status, or implementation-level CLI/schema detail. Resolve mutable facts from the owning repositories and machine-readable Platform/Bootstrap/consumer provenance.
137
+
129
138
  ## Maintainer release process
130
139
 
131
140
  Project Forge maintainers use a gated release workflow that resolves accepted component commits, verifies the assembled package, creates the package pull request, verifies CI, and stops at the explicit publication gate.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.4.0",
4
+ "version": "4.4.2",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -14,7 +14,7 @@
14
14
  "components": {
15
15
  "core": {
16
16
  "repository": "mriekki/project-forge-core",
17
- "commit": "45139179c3d10ebd89702ac3aedec231364d8a34",
17
+ "commit": "d106b1834a8c44bdae0b16e4af5d8dab0711a120",
18
18
  "version": "4.0.0",
19
19
  "distribution": "greenfield-workspace",
20
20
  "descriptor": "assets/core/distribution.json",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "deployment": {
25
25
  "repository": "mriekki/project-forge-deployment-kit",
26
- "commit": "eb157c8ae2c638ae1cd08bf27446d430835adc80",
26
+ "commit": "880a911bc87e38c74ca4b336c9563a6f026ac3b4",
27
27
  "distribution": "greenfield-project",
28
28
  "descriptor": "assets/deployment/distribution.json",
29
29
  "sourceRoot": "assets/generated/deployment",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.4.0",
4
+ "version": "4.4.2",
5
5
  "components": {
6
6
  "core": {
7
7
  "repository": "mriekki/project-forge-core",
8
- "commit": "45139179c3d10ebd89702ac3aedec231364d8a34",
8
+ "commit": "d106b1834a8c44bdae0b16e4af5d8dab0711a120",
9
9
  "version": "4.0.0",
10
10
  "distribution": "greenfield-workspace",
11
11
  "files": [
@@ -22,7 +22,7 @@
22
22
  {
23
23
  "source": "core/instructions/project-forge-workflow.instructions.md",
24
24
  "destination": ".github/instructions/project-forge-workflow.instructions.md",
25
- "sha256": "85568ba56681831e34c63aa14f91702522a451e8b2fa883da631d609440d7a7c"
25
+ "sha256": "13379a56c65921756cdae8e1d2d711c3321b9d899b5d96a81dbf51ecaa9ce022"
26
26
  },
27
27
  {
28
28
  "source": "core/instructions/project-forge-docs.instructions.md",
@@ -42,7 +42,7 @@
42
42
  {
43
43
  "source": "core/skills/refine-requirements/SKILL.md",
44
44
  "destination": ".github/skills/refine-requirements/SKILL.md",
45
- "sha256": "ee5085ac6bbf8fb33ab5f6b52e0ac954c164fd3ea51f90965c9bde86adc88def"
45
+ "sha256": "9e76b9c3700a00af8852928d4e6bf1aeca9a24c6cc8fc9b2f1873e0198b63962"
46
46
  },
47
47
  {
48
48
  "source": "core/skills/start-work/SKILL.md",
@@ -187,7 +187,7 @@
187
187
  {
188
188
  "source": "core/process/INTERACTIVE_SESSION_GUIDE.md",
189
189
  "destination": "docs/process/INTERACTIVE_SESSION_GUIDE.md",
190
- "sha256": "8cd74fbd7433969923299849eb89bdd774fd4af1b9694639f0b9871a1047cacf"
190
+ "sha256": "08df5212fc6d08e46d3121dad3d076e05baa71b046a7e360a201b18916b7e574"
191
191
  },
192
192
  {
193
193
  "source": "core/process/COACHING_GUIDE.md",
@@ -328,7 +328,7 @@
328
328
  },
329
329
  "deployment": {
330
330
  "repository": "mriekki/project-forge-deployment-kit",
331
- "commit": "eb157c8ae2c638ae1cd08bf27446d430835adc80",
331
+ "commit": "880a911bc87e38c74ca4b336c9563a6f026ac3b4",
332
332
  "distribution": "greenfield-project",
333
333
  "files": [
334
334
  {
@@ -519,7 +519,7 @@
519
519
  {
520
520
  "source": "src/project_forge_deployment/sqlite_adapter.py",
521
521
  "destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter.py",
522
- "sha256": "b6f5fd33b776a1fb02d6b6a00fbab0b95827946e5fee51aafef9446fa084f42b"
522
+ "sha256": "011615339f739316fb1350a7d4f1385a841744cf09c66da61c10e46f444c059f"
523
523
  },
524
524
  {
525
525
  "source": "src/project_forge_deployment/sqlite_adapter_remote.py",
@@ -529,12 +529,12 @@
529
529
  {
530
530
  "source": "src/project_forge_deployment/sqlite_cli.py",
531
531
  "destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_cli.py",
532
- "sha256": "e56fe51fe92110704f709f9b4a3e89f667aa4f827dbf562262b3f89ab7637dfb"
532
+ "sha256": "abee3a7fbef21f1a02b1511f5d050f90d9ef51afcd6714046c25b786568fb833"
533
533
  },
534
534
  {
535
535
  "source": "src/project_forge_deployment/sqlite_import_recovery.py",
536
536
  "destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_import_recovery.py",
537
- "sha256": "417096927ae2f36d19df90e26ed88a1f8659a83538155d3538f660a887077f4e"
537
+ "sha256": "b047494e403963120d8667e189f20ed6310526870b56adb1f433a9b0154c7636"
538
538
  },
539
539
  {
540
540
  "source": "src/project_forge_deployment/ssh.py",
@@ -20,9 +20,9 @@ For implementation, establish the repository-appropriate short-lived work branch
20
20
 
21
21
  Continuous modes such as `/grill-me`, `/refine-requirements`, `/coach`, `/learning-review`, and `/deployment-readiness` continue until stopped or naturally complete.
22
22
 
23
- During interactive questioning, prefer concise choice-first options for genuinely bounded human decisions, including `Other / specify` and `Not sure — recommend one` when appropriate. When the host exposes native structured question controls, use them instead of a plain numbered chat list. In VS Code, use the host's structured question surface such as `vscode_askQuestions` when available: single-select for mutually exclusive decisions/actions and multi-select only when several simultaneous selections are valid. A textual numbered menu is only the fallback when structured choice UI is unavailable.
23
+ During interactive questioning, prefer concise choice-first options for genuinely bounded human decisions, including `Other / specify` and `Not sure — recommend one` when appropriate. When the host exposes native structured question controls, use them instead of a plain numbered chat list. In VS Code, use the host's structured question surface such as `vscode_askQuestions` when available: single-select for mutually exclusive decisions/actions and multi-select only when several simultaneous selections are valid. A textual numbered or lettered menu is only the fallback when structured choice UI is unavailable.
24
24
 
25
- Make a recommendation only when current evidence, repository policy, or a clear engineering tradeoff supports one. Mark exactly one supported choice with the host's `recommended` indicator when available and give a brief rationale. If no option is clearly better, do not mark any option recommended. A recommendation is advice, not approval: record only the human's selected/confirmed answer. If the human asks for no recommendation on one decision, honor that for that decision only; later questions may still include evidence-backed recommendations.
25
+ Make a recommendation only when current evidence, repository policy, or a clear engineering tradeoff supports one. Mark exactly one supported choice with the host's `recommended` indicator when available and give a brief rationale. When the host does not expose recommendation metadata, append `[RECOMMENDED]` to exactly one option label and keep the rationale visible. A default/preselected option may match the recommendation, but a default alone must never communicate advice or human approval. If no option is clearly better, do not mark any option recommended and do not use a default that implies a preference. A recommendation is advice, not approval: record only the human's selected/confirmed answer. If the human asks for no recommendation on one decision, honor that for that decision only; later questions may still include evidence-backed recommendations.
26
26
 
27
27
  Keep factual/environment-specific values and questions intended to elicit the human's own framing, reasoning, debugging approach, design rationale, or understanding open-ended unless options are later useful as scaffolding.
28
28
 
@@ -51,16 +51,18 @@ A recommendation is optional advice, not a required field and not an accepted de
51
51
 
52
52
  - mark **exactly one** choice as `RECOMMENDED` only when current evidence, repository policy, or a clear engineering tradeoff supports it;
53
53
  - use the host's `recommended` indicator/metadata when available and add a brief rationale in the choice description or surrounding prompt;
54
+ - when the host does not expose recommendation metadata, append `[RECOMMENDED]` to exactly one option label and keep the concise rationale visible in the prompt or option description;
55
+ - a default/preselected value may match the recommended option, but a default alone must never communicate that an option is recommended or human-approved;
54
56
  - do not mark multiple choices recommended;
55
57
  - do not invent a preference merely to populate recommendation metadata;
56
- - when no option is clearly better, present the choices with **no recommendation**;
58
+ - when no option is clearly better, present the choices with **no recommendation** and do not use a default that implies a preference;
57
59
  - if the human asks for no recommendation on a specific decision, omit the recommendation for that question only; later questions may still include evidence-backed recommendations;
58
60
  - the human may select a different option or answer free-form;
59
61
  - record only the human's selected/confirmed answer as the requirement, action, or decision. Never record the recommendation itself as accepted state.
60
62
 
61
63
  A structured selector is presentation, not extra authorization. Selecting a choice authorizes only the action/session that the workflow says that choice authorizes.
62
64
 
63
- When the host has no structured question UI, use a concise textual fallback. A numbered list is acceptable **only as this fallback**; keep the same labels/recommendation/consequence tags and accept the number, label, or a free-form answer. Do not use a plain numbered list as the normal VS Code interaction when native structured choice UI is available.
65
+ When the host has no structured question UI, use a concise textual fallback. A numbered or lettered list is acceptable **only as this fallback**; keep the same labels/recommendation/consequence tags, including `[RECOMMENDED]` when one option is evidence-backed, and accept the number/letter, label, or a free-form answer. Do not use a plain numbered list as the normal VS Code interaction when native structured choice UI is available.
64
66
 
65
67
  Keep questions open-ended when predefined choices would weaken the purpose of the interaction. This includes:
66
68
 
@@ -124,9 +124,9 @@ Then present only relevant actions as a **single-select structured choice** when
124
124
  - `Approve local requirements` `[REQUIREMENTS GATE]` when applicable
125
125
  - `Stop` `[NO ACTION]`
126
126
 
127
- Make a recommendation only when current requirement evidence or a clear engineering tradeoff supports one. If so, mark exactly one choice with the host's `recommended` indicator and briefly explain why. If there is no clear basis, show the choices without a recommendation. The selected answer, not the recommendation, controls what the session does or records.
127
+ Make a recommendation only when current requirement evidence or a clear engineering tradeoff supports one. If so, mark exactly one choice with the host's `recommended` indicator when that metadata exists; otherwise append `[RECOMMENDED]` to exactly one option label. Briefly explain why in the option description or surrounding prompt. A default/preselected value may match the recommendation, but a default alone must never communicate advice or approval. If there is no clear basis, show the choices without a recommendation and without a preference-implying default. The selected answer, not the recommendation, controls what the session does or records.
128
128
 
129
- If the host has no structured choice UI, present the same relevant choices as a concise numbered text fallback and accept the number, label, or free-form intent. Do not use the numbered fallback as the normal VS Code path.
129
+ If the host has no structured choice UI, present the same relevant choices as a concise numbered or lettered text fallback, preserve `[RECOMMENDED]` on the evidence-backed option when applicable, and accept the number/letter, label, or free-form intent. Do not use the text fallback as the normal VS Code path.
130
130
 
131
131
  ## 5. Active refinement session
132
132
 
@@ -143,7 +143,7 @@ ask one material requirement question
143
143
 
144
144
  For bounded requirement decisions such as lifecycle policy, validation behavior, destructive/reversible handling, or a small set of credible alternatives, use the choice-first interaction guidance from `INTERACTIVE_SESSION_GUIDE.md`. Render mutually exclusive alternatives through host-native **single-select** controls when available. Use multi-select only when the requirement genuinely permits multiple simultaneous selections. Keep `Other / specify` or free-text available when the option set may be incomplete. Keep outcome wording, workflow descriptions, acceptance intent, and other human-authored framing open-ended when predefined choices would constrain the requirement.
145
145
 
146
- Recommendation behavior follows the shared guide: recommend at most one option, only when evidence/tradeoffs support it; give a concise rationale; never record the recommendation as accepted until the human selects or confirms it; and honor a `no recommendation` request for that question only.
146
+ Recommendation behavior follows the shared guide: recommend at most one option, only when evidence/tradeoffs support it; make the recommendation visibly distinguishable through native metadata or `[RECOMMENDED]`; give a concise rationale; never record the recommendation as accepted until the human selects or confirms it; and honor a `no recommendation` request for that question only.
147
147
 
148
148
  Example of a supported recommendation:
149
149
 
@@ -153,11 +153,8 @@ Decision: How should a browser-to-real-SQLite path be proven for a narrowly scop
153
153
  Choices:
154
154
  - Integration test against real SQLite
155
155
  - Browser smoke check through the API
156
- - Both
156
+ - Both [RECOMMENDED] — proves persistence and browser/API paths without implying a broad end-to-end suite
157
157
  - Other / specify
158
-
159
- Recommendation: Both
160
- Why: one real-SQLite integration test proves the persistence path and one browser smoke check proves the browser/API path without implying a broad end-to-end suite.
161
158
  ```
162
159
 
163
160
  Treat this as advice for the human to accept, change, or replace. Do not record `Both` as a requirement until the human selects or confirms it.
@@ -252,9 +252,18 @@ def _reservation_from_request(request: dict[str, Any]) -> dict[str, Any]:
252
252
  }
253
253
 
254
254
 
255
- def plan_sqlite_operation(name: str, action: str, request: dict[str, Any]) -> dict[str, Any]:
255
+ def plan_sqlite_operation(
256
+ name: str,
257
+ action: str,
258
+ request: dict[str, Any],
259
+ *,
260
+ production: bool = False,
261
+ ) -> dict[str, Any]:
256
262
  if action not in {"import", "backup", "migrate", "rollback-check"}:
257
263
  raise DeploymentKitError("Unsupported SQLite plan action.")
264
+ if not isinstance(production, bool):
265
+ raise DeploymentKitError("SQLite Production classification must be a boolean.")
266
+ production_classified = production or request["environment"] == "production"
258
267
  _require_ready_platform(name)
259
268
  inventory = sqlite_inventory(name)
260
269
  existing = _find(inventory, request["appSlug"], request["environment"])
@@ -284,7 +293,8 @@ def plan_sqlite_operation(name: str, action: str, request: dict[str, Any]) -> di
284
293
  raise DeploymentKitError(f"SQLite {action} requires an existing database.")
285
294
  return {
286
295
  "targetName": name, "action": action, "ownerRepo": request["ownerRepo"],
287
- "environment": request["environment"], "appSlug": request["appSlug"], "image": request["image"],
296
+ "environment": request["environment"], "productionClassified": production_classified,
297
+ "appSlug": request["appSlug"], "image": request["image"],
288
298
  "databaseFile": spec["databaseFile"], "runAs": spec["runAs"],
289
299
  "sourceDatabase": request["sourceDatabase"] if action == "import" else None,
290
300
  "firstInstall": bool(request["firstInstall"] if action == "migrate" else False),
@@ -295,7 +305,9 @@ def plan_sqlite_operation(name: str, action: str, request: dict[str, Any]) -> di
295
305
 
296
306
 
297
307
  def authorization_phrase(plan: dict[str, Any]) -> str:
298
- production = plan["environment"] == "production"
308
+ production = plan.get("productionClassified")
309
+ if not isinstance(production, bool):
310
+ raise DeploymentKitError("Malformed SQLite operation plan Production classification.")
299
311
  mapping = {
300
312
  "import": (SQLITE_IMPORT_WRITE_PHRASE, PRODUCTION_SQLITE_IMPORT_WRITE_PHRASE),
301
313
  "backup": (SQLITE_BACKUP_WRITE_PHRASE, PRODUCTION_SQLITE_BACKUP_WRITE_PHRASE),
@@ -307,9 +319,11 @@ def authorization_phrase(plan: dict[str, Any]) -> str:
307
319
 
308
320
 
309
321
  def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
310
- required = {"targetName", "action", "ownerRepo", "environment", "appSlug", "image", "databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup", "inventorySnapshot", "expectedReservationSha256", "spec"}
311
- if not isinstance(plan, dict) or set(plan) != required:
322
+ required = {"targetName", "action", "ownerRepo", "environment", "productionClassified", "appSlug", "image", "databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup", "inventorySnapshot", "expectedReservationSha256", "spec"}
323
+ if not isinstance(plan, dict) or set(plan) != required or not isinstance(plan["productionClassified"], bool):
312
324
  raise DeploymentKitError("Malformed SQLite operation plan.")
325
+ if plan["environment"] == "production" and not plan["productionClassified"]:
326
+ raise DeploymentKitError("Literal production environment must remain Production-classified.")
313
327
  _require_ready_platform(plan["targetName"])
314
328
  current = sqlite_inventory(plan["targetName"])
315
329
  if _snapshot(current) != plan["inventorySnapshot"]:
@@ -319,6 +333,7 @@ def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
319
333
  result = sqlite_inventory(plan["targetName"])
320
334
  result["mutated"] = True
321
335
  result["operation"] = plan["action"]; result["operationOutput"] = output.strip()
336
+ result["productionClassified"] = plan["productionClassified"]
322
337
  return result
323
338
 
324
339
 
@@ -332,4 +347,4 @@ def assert_app_spec_matches_persistence(name: str, app_spec: dict[str, Any]) ->
332
347
  if existing["runAs"] != app_spec["runAs"]:
333
348
  raise DeploymentKitError("Application runAs does not match the prepared SQLite persistence reservation.")
334
349
  if app_spec.get("persistence", {}).get("mode") != "directory":
335
- raise DeploymentKitError("Prepared SQLite persistence requires directory application persistence.")
350
+ raise DeploymentKitError("Prepared SQLite persistence requires directory application persistence.")
@@ -38,6 +38,11 @@ def _common(parser: argparse.ArgumentParser) -> None:
38
38
  parser.add_argument('name')
39
39
  parser.add_argument('--owner-repo', required=True)
40
40
  parser.add_argument('--environment', required=True)
41
+ parser.add_argument(
42
+ '--production',
43
+ action='store_true',
44
+ help="Classify this named environment as Production even when its name is not literally 'production'",
45
+ )
41
46
  parser.add_argument('--app-slug', required=True)
42
47
  parser.add_argument('--image', required=True)
43
48
  parser.add_argument('--maintenance-env', action='append', default=[], help='Non-secret image maintenance environment value in NAME=value form; repeat as needed')
@@ -68,19 +73,20 @@ def main(argv: list[str]|None=None) -> int:
68
73
  args=build_parser().parse_args(argv)
69
74
  try:
70
75
  if args.command=='inventory': _print(sqlite_inventory(args.name),as_json=args.json); return 0
76
+ production_classified = args.production or args.environment == 'production'
71
77
  request=normalize_sqlite_request(owner_repo=args.owner_repo,environment=args.environment,app_slug=args.app_slug,image=args.image,
72
78
  maintenance_environment=_parse_env(args.maintenance_env),run_as=getattr(args,'run_as',None),database_file=getattr(args,'database_file',None),
73
79
  source_database=getattr(args,'source_database',None),first_install=getattr(args,'first_install',False))
74
80
  if args.command=='recover-import':
75
- plan=plan_sqlite_import_recovery(args.name,request)
81
+ plan=plan_sqlite_import_recovery(args.name,request,production=production_classified)
76
82
  _print({k:v for k,v in plan.items() if k!='request'},as_json=False)
77
83
  phrase=recovery_authorization_phrase(plan)
78
84
  _confirm(args,phrase,f"recover SQLite import for {plan['appSlug']} ({plan['environment']}) on target {args.name}")
79
85
  _print(apply_sqlite_import_recovery(plan),as_json=args.json); return 0
80
- plan=plan_sqlite_operation(args.name,args.command,request)
86
+ plan=plan_sqlite_operation(args.name,args.command,request,production=production_classified)
81
87
  _print({k:v for k,v in plan.items() if k!='spec'},as_json=False)
82
88
  phrase=authorization_phrase(plan)
83
89
  _confirm(args,phrase,f"run SQLite {args.command} for {plan['appSlug']} ({plan['environment']}) on target {args.name}")
84
90
  _print(apply_sqlite_operation(plan),as_json=args.json); return 0
85
91
  except DeploymentKitError as error:
86
- print(f"ERROR: {error}",file=sys.stderr); return 2
92
+ print(f"ERROR: {error}",file=sys.stderr); return 2
@@ -356,13 +356,22 @@ def _snapshot(inspection: dict[str, Any]) -> str:
356
356
  return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
357
357
 
358
358
 
359
- def plan_sqlite_import_recovery(name: str, request: dict[str, Any]) -> dict[str, Any]:
359
+ def plan_sqlite_import_recovery(
360
+ name: str,
361
+ request: dict[str, Any],
362
+ *,
363
+ production: bool = False,
364
+ ) -> dict[str, Any]:
365
+ if not isinstance(production, bool):
366
+ raise DeploymentKitError("SQLite Production classification must be a boolean.")
367
+ production_classified = production or request["environment"] == "production"
360
368
  inspection = inspect_sqlite_import_recovery(name, request)
361
369
  return {
362
370
  "targetName": name,
363
371
  "action": "recover-import",
364
372
  "ownerRepo": request["ownerRepo"],
365
373
  "environment": request["environment"],
374
+ "productionClassified": production_classified,
366
375
  "appSlug": request["appSlug"],
367
376
  "image": request["image"],
368
377
  "runAs": request["runAs"],
@@ -381,22 +390,32 @@ def plan_sqlite_import_recovery(name: str, request: dict[str, Any]) -> dict[str,
381
390
 
382
391
 
383
392
  def authorization_phrase(plan: dict[str, Any]) -> str:
393
+ production = plan.get("productionClassified")
394
+ if not isinstance(production, bool):
395
+ raise DeploymentKitError("Malformed SQLite import recovery Production classification.")
384
396
  return (
385
397
  PRODUCTION_SQLITE_IMPORT_RECOVERY_WRITE_PHRASE
386
- if plan["environment"] == "production"
398
+ if production
387
399
  else SQLITE_IMPORT_RECOVERY_WRITE_PHRASE
388
400
  )
389
401
 
390
402
 
391
403
  def apply_sqlite_import_recovery(plan: dict[str, Any]) -> dict[str, Any]:
392
404
  required = {
393
- "targetName", "action", "ownerRepo", "environment", "appSlug", "image", "runAs",
405
+ "targetName", "action", "ownerRepo", "environment", "productionClassified", "appSlug", "image", "runAs",
394
406
  "databaseFile", "sourceDatabase", "databaseSha256", "sourceSha256", "databaseBytes",
395
407
  "stateSnapshot", "overwritesDatabase", "deletesPersistentData", "createsReservation",
396
408
  "reverifiesDatabase", "request",
397
409
  }
398
- if not isinstance(plan, dict) or set(plan) != required or plan["action"] != "recover-import":
410
+ if (
411
+ not isinstance(plan, dict)
412
+ or set(plan) != required
413
+ or plan["action"] != "recover-import"
414
+ or not isinstance(plan["productionClassified"], bool)
415
+ ):
399
416
  raise DeploymentKitError("Malformed SQLite import recovery plan.")
417
+ if plan["environment"] == "production" and not plan["productionClassified"]:
418
+ raise DeploymentKitError("Literal production environment must remain Production-classified.")
400
419
  current = inspect_sqlite_import_recovery(plan["targetName"], plan["request"])
401
420
  if _snapshot(current) != plan["stateSnapshot"]:
402
421
  raise DeploymentKitError(
@@ -434,4 +453,5 @@ def apply_sqlite_import_recovery(plan: dict[str, Any]) -> dict[str, Any]:
434
453
  inventory["mutated"] = True
435
454
  inventory["operation"] = "recover-import"
436
455
  inventory["operationOutput"] = output.strip()
456
+ inventory["productionClassified"] = plan["productionClassified"]
437
457
  return inventory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.4.0",
3
+ "version": "4.4.2",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {