specromancy-engine 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. specromancy_engine/__init__.py +32 -0
  2. specromancy_engine/__main__.py +8 -0
  3. specromancy_engine/_adapters/__init__.py +1 -0
  4. specromancy_engine/_adapters/adapter_contracts.py +81 -0
  5. specromancy_engine/_adapters/adapter_ownership.py +302 -0
  6. specromancy_engine/_adapters/adapter_rendering.py +218 -0
  7. specromancy_engine/_adapters/adapter_sources.py +140 -0
  8. specromancy_engine/_cli/__init__.py +1 -0
  9. specromancy_engine/_cli/cli_commands.py +136 -0
  10. specromancy_engine/_cli/pipeline_selection.py +28 -0
  11. specromancy_engine/_config/__init__.py +1 -0
  12. specromancy_engine/_config/config_errors.py +57 -0
  13. specromancy_engine/_config/config_fields.py +228 -0
  14. specromancy_engine/_config/config_loader.py +246 -0
  15. specromancy_engine/_config/config_models.py +97 -0
  16. specromancy_engine/_config/config_phase_parser.py +497 -0
  17. specromancy_engine/_config/config_serialization.py +76 -0
  18. specromancy_engine/_engine/__init__.py +1 -0
  19. specromancy_engine/_engine/approval_service.py +278 -0
  20. specromancy_engine/_engine/approvals.py +248 -0
  21. specromancy_engine/_engine/command_decisions.py +81 -0
  22. specromancy_engine/_engine/engine_errors.py +22 -0
  23. specromancy_engine/_engine/provenance.py +58 -0
  24. specromancy_engine/_engine/responses.py +129 -0
  25. specromancy_engine/_engine/validation_service.py +112 -0
  26. specromancy_engine/_runs/__init__.py +1 -0
  27. specromancy_engine/_runs/run_errors.py +51 -0
  28. specromancy_engine/_runs/run_identity.py +66 -0
  29. specromancy_engine/_runs/run_persistence.py +303 -0
  30. specromancy_engine/_runs/run_records.py +159 -0
  31. specromancy_engine/_runs/run_validation.py +241 -0
  32. specromancy_engine/_runs/visit_preparation.py +61 -0
  33. specromancy_engine/_runs/visit_transitions.py +311 -0
  34. specromancy_engine/_version.py +18 -0
  35. specromancy_engine/actions.py +118 -0
  36. specromancy_engine/adapters.py +97 -0
  37. specromancy_engine/artifacts.py +303 -0
  38. specromancy_engine/cli.py +338 -0
  39. specromancy_engine/commands.py +140 -0
  40. specromancy_engine/config.py +63 -0
  41. specromancy_engine/contracts.py +20 -0
  42. specromancy_engine/engine.py +423 -0
  43. specromancy_engine/errors.py +42 -0
  44. specromancy_engine/exit_codes.py +53 -0
  45. specromancy_engine/git.py +264 -0
  46. specromancy_engine/graph.py +321 -0
  47. specromancy_engine/hashing.py +95 -0
  48. specromancy_engine/locking.py +107 -0
  49. specromancy_engine/registry.py +134 -0
  50. specromancy_engine/run_store.py +643 -0
  51. specromancy_engine/schema_validation.py +178 -0
  52. specromancy_engine/schemas/event.schema.json +34 -0
  53. specromancy_engine/schemas/pipeline.schema.json +154 -0
  54. specromancy_engine/schemas/pipelines.schema.json +38 -0
  55. specromancy_engine/schemas/run.schema.json +152 -0
  56. specromancy_engine/status.py +106 -0
  57. specromancy_engine/validation.py +241 -0
  58. specromancy_engine-0.1.3.dist-info/METADATA +122 -0
  59. specromancy_engine-0.1.3.dist-info/RECORD +63 -0
  60. specromancy_engine-0.1.3.dist-info/WHEEL +5 -0
  61. specromancy_engine-0.1.3.dist-info/entry_points.txt +2 -0
  62. specromancy_engine-0.1.3.dist-info/licenses/LICENSE +21 -0
  63. specromancy_engine-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,278 @@
1
+ """Coordinate approval requests, grants, invalidation, and continuation.
2
+
3
+ The caller supplies a strictly loaded manifest for requests. Grants deliberately
4
+ load persisted state here before checking bindings, so drift can be audited.
5
+ Validation collects evidence and records expected failures through an explicit
6
+ collaborator; successful evidence is committed with the request or transition.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from . import responses
15
+ from .approvals import (
16
+ approval_integrity_errors, approved_for_phase, approved_record,
17
+ build_approval_record, conflict, grant_decision, invalidate_decision,
18
+ pending_approval, request_decision, require_binding,
19
+ )
20
+ from ..artifacts import ArtifactError, artifact_record
21
+ from .command_decisions import declared_reason, illegal_transition, select_outcome
22
+ from .._config.config_models import PhaseConfig, PipelineConfig
23
+ from .engine_errors import EngineError
24
+ from ..exit_codes import ExitCode
25
+ from .provenance import observe_provenance
26
+ from .._runs.run_records import ApprovalRecord, RunRecord, VisitRecord, current_visit
27
+ from ..run_store import RunStore
28
+ from .validation_service import ValidationResult
29
+
30
+
31
+ class ApprovalService:
32
+ """Own approval ordering with explicit validation and timestamp seams."""
33
+
34
+ def __init__(
35
+ self,
36
+ pipeline: PipelineConfig,
37
+ store: RunStore,
38
+ *,
39
+ validate: Callable[[RunRecord, VisitRecord, PhaseConfig], ValidationResult],
40
+ timestamp: Callable[[], str],
41
+ ) -> None:
42
+ self.pipeline = pipeline
43
+ self.store = store
44
+ self._perform_validation = validate
45
+ self._timestamp = timestamp
46
+
47
+ def invalidate_approval(
48
+ self,
49
+ manifest: RunRecord,
50
+ visit: VisitRecord,
51
+ approval: ApprovalRecord,
52
+ mismatches: list[str],
53
+ ) -> None:
54
+ decided_at = self._timestamp()
55
+
56
+ self.store.decide_approval(
57
+ manifest["run_id"],
58
+ lambda value: invalidate_decision(
59
+ value, approval, decided_at=decided_at, mismatches=mismatches,
60
+ ),
61
+ )
62
+
63
+ def advance_approved(
64
+ self,
65
+ manifest: RunRecord,
66
+ visit: VisitRecord,
67
+ approval: ApprovalRecord,
68
+ ) -> dict[str, Any]:
69
+ require_binding(manifest, approval)
70
+ transition = next((
71
+ item for item in self.pipeline.phase(visit["phase_id"]).transitions
72
+ if item.outcome == approval["outcome"]
73
+ ), None)
74
+ decision = self.store.decide_approval(
75
+ manifest["run_id"], lambda value: grant_decision(
76
+ value, approval, transition_target=transition.target if transition else None,
77
+ decided_at=None,
78
+ ),
79
+ )
80
+ manifest, approval = decision.manifest, decision.approval
81
+ if decision.disposition == "completed":
82
+ return responses.response_for_state(
83
+ self.pipeline, manifest, "approval was already recorded"
84
+ )
85
+ visit = current_visit(manifest)
86
+ try:
87
+ current_hash = self.artifact_hash(manifest, visit)
88
+ except EngineError:
89
+ current_hash = ""
90
+ mismatches = approval_integrity_errors(
91
+ approval,
92
+ artifact_sha256=current_hash,
93
+ pipeline_sha256=self.pipeline.config_hash,
94
+ )
95
+ if mismatches:
96
+ self.invalidate_approval(manifest, visit, approval, mismatches)
97
+ raise EngineError(
98
+ ExitCode.APPROVAL_REQUIRED,
99
+ "approval is stale because its artifact or pipeline changed",
100
+ "stale-approval",
101
+ phase=visit["phase_id"],
102
+ visit_number=visit["ordinal"],
103
+ mismatches=mismatches,
104
+ )
105
+ if transition is None:
106
+ raise conflict(approval)
107
+ phase = self.pipeline.phase(visit["phase_id"])
108
+ evidence = self._perform_validation(manifest, visit, phase)
109
+ updated = self.store.transition_visit(
110
+ manifest["run_id"],
111
+ self.pipeline,
112
+ visit["ordinal"],
113
+ outcome=approval["outcome"],
114
+ transition_target=transition.target,
115
+ terminal_result={
116
+ "outcome": approval["outcome"],
117
+ "phase": phase.id,
118
+ "visit_number": visit["ordinal"],
119
+ "approval_reason": approval["reason"],
120
+ },
121
+ validation_checks=evidence.checks,
122
+ mutation_result=evidence.mutation_result,
123
+ command_results=evidence.command_results,
124
+ )
125
+ return responses.after_transition(self.pipeline, updated, approval["outcome"])
126
+
127
+ def request_approval(
128
+ self,
129
+ manifest: RunRecord,
130
+ *,
131
+ reason: str | None,
132
+ details: str | None = None,
133
+ outcome: str | None = None,
134
+ ) -> dict[str, Any]:
135
+ run_id = manifest["run_id"]
136
+ visit = current_visit(manifest)
137
+ if visit is None or visit["status"] not in {"active", "awaiting-approval"}:
138
+ raise illegal_transition("only an active visit can request approval", manifest)
139
+ phase = self.pipeline.phase(visit["phase_id"])
140
+ chosen_reason = declared_reason(reason, phase.approval_conditions, "approval")
141
+ selected_outcome = select_outcome(phase, outcome)
142
+ evidence = self._perform_validation(manifest, visit, phase)
143
+ check = evidence.checks[0]
144
+ existing = pending_approval(manifest, visit["ordinal"])
145
+ if existing is not None:
146
+ require_binding(manifest, existing)
147
+ if not (
148
+ existing["reason"] == chosen_reason
149
+ and existing["details"] == details
150
+ and existing["artifact_sha256"] == check["sha256"]
151
+ and existing["outcome"] == selected_outcome
152
+ ):
153
+ raise illegal_transition("a different approval is already pending", manifest)
154
+ approved = approved_record(manifest, visit["ordinal"])
155
+ if approved is not None:
156
+ return self.advance_approved(manifest, visit, approved)
157
+
158
+ requested_at = existing["requested_at"] if existing else self._timestamp()
159
+ approval = build_approval_record(
160
+ run_id=run_id,
161
+ phase_id=phase.id,
162
+ visit_number=visit["ordinal"],
163
+ reason=chosen_reason,
164
+ details=details,
165
+ artifact_sha256=check["sha256"],
166
+ pipeline_sha256=self.pipeline.config_hash,
167
+ outcome=selected_outcome,
168
+ requested_at=requested_at,
169
+ )
170
+
171
+ decision = self.store.decide_approval(
172
+ run_id, lambda value: request_decision(
173
+ value, approval, validation_checks=evidence.checks,
174
+ mutation_result=evidence.mutation_result,
175
+ command_results=evidence.command_results,
176
+ ),
177
+ )
178
+ return responses.approval_response(
179
+ self.pipeline, decision.manifest, decision.approval,
180
+ "approval is required" if decision.disposition == "requested"
181
+ else "approval is already pending",
182
+ )
183
+
184
+ def approve(self, run_id: str, phase_id: str) -> dict[str, Any]:
185
+ # Approval verification intentionally loads persisted state before
186
+ # enforcing the current pipeline hash so drift can be recorded as a
187
+ # stale approval instead of becoming an unaudited early error.
188
+ manifest = self.store.load(run_id)
189
+ visit = current_visit(manifest)
190
+ if visit is None:
191
+ return self.idempotent_approval_result(manifest, phase_id)
192
+ if visit["phase_id"] != phase_id or visit["status"] != "awaiting-approval":
193
+ return self.idempotent_approval_result(manifest, phase_id)
194
+ approval = pending_approval(manifest, visit["ordinal"])
195
+ if approval is None:
196
+ approved = approved_record(manifest, visit["ordinal"])
197
+ if approved is not None:
198
+ return self.advance_approved(manifest, visit, approved)
199
+ raise illegal_transition("visit has no pending approval request", manifest)
200
+ require_binding(manifest, approval)
201
+ try:
202
+ current_hash = self.artifact_hash(manifest, visit)
203
+ except EngineError:
204
+ current_hash = ""
205
+ mismatches = approval_integrity_errors(
206
+ approval,
207
+ artifact_sha256=current_hash,
208
+ pipeline_sha256=self.pipeline.config_hash,
209
+ )
210
+ if mismatches:
211
+ self.invalidate_approval(manifest, visit, approval, mismatches)
212
+ raise EngineError(
213
+ ExitCode.APPROVAL_REQUIRED,
214
+ "approval request is stale because its artifact or pipeline changed",
215
+ "stale-approval",
216
+ phase=phase_id,
217
+ visit_number=visit["ordinal"],
218
+ mismatches=mismatches,
219
+ )
220
+ provenance_warnings = observe_provenance(self.pipeline, manifest)
221
+ if provenance_warnings:
222
+ warning = provenance_warnings[0]
223
+ raise EngineError(
224
+ ExitCode.INTERNAL_ERROR,
225
+ warning["message"],
226
+ warning["code"],
227
+ **warning.get("details", {}),
228
+ )
229
+ decided_at = self._timestamp()
230
+
231
+ transition = next((
232
+ item for item in self.pipeline.phase(phase_id).transitions
233
+ if item.outcome == approval["outcome"]
234
+ ), None)
235
+ if transition is None:
236
+ raise conflict(approval)
237
+ decision = self.store.decide_approval(
238
+ run_id, lambda value: grant_decision(
239
+ value, approval, decided_at=decided_at, transition_target=transition.target,
240
+ ),
241
+ )
242
+ if decision.disposition == "completed":
243
+ return responses.response_for_state(
244
+ self.pipeline, decision.manifest, "approval was already recorded"
245
+ )
246
+ return self.advance_approved(
247
+ decision.manifest, current_visit(decision.manifest), decision.approval,
248
+ )
249
+
250
+ def artifact_hash(
251
+ self, manifest: RunRecord, visit: VisitRecord
252
+ ) -> str:
253
+ try:
254
+ return artifact_record(
255
+ self.store.run_directory(manifest["run_id"]),
256
+ visit["output"]["path"],
257
+ )["sha256"]
258
+ except (ArtifactError, OSError) as exc:
259
+ details = dict(getattr(exc, "details", None) or {"error": str(exc)})
260
+ details.pop("error_code", None)
261
+ raise EngineError(
262
+ ExitCode.VALIDATION_FAILED,
263
+ "output artifact is missing or unreadable",
264
+ "invalid-output",
265
+ **details,
266
+ ) from exc
267
+
268
+ def idempotent_approval_result(
269
+ self, manifest: RunRecord, phase_id: str
270
+ ) -> dict[str, Any]:
271
+ approved = approved_for_phase(manifest, phase_id)
272
+ if approved is None:
273
+ raise illegal_transition(
274
+ f"phase {phase_id!r} has no pending approval", manifest
275
+ )
276
+ return responses.response_for_state(
277
+ self.pipeline, manifest, "approval was already recorded"
278
+ )
@@ -0,0 +1,248 @@
1
+ """Pure approval selectors, integrity checks, and detached state changes.
2
+
3
+ Callers supply timestamps and observations; decisions run against locked state.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import copy
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from .engine_errors import EngineError
13
+ from ..exit_codes import ExitCode
14
+ from .._runs.run_records import ApprovalRecord, RunRecord, VisitRecord, current_visit, latest_approval
15
+
16
+
17
+ def build_approval_record(
18
+ *,
19
+ run_id: str,
20
+ phase_id: str,
21
+ visit_number: int,
22
+ reason: str,
23
+ details: str | None,
24
+ artifact_sha256: str,
25
+ pipeline_sha256: str,
26
+ outcome: str,
27
+ requested_at: str,
28
+ ) -> ApprovalRecord:
29
+ return {
30
+ "run_id": run_id,
31
+ "phase_id": phase_id,
32
+ "visit_number": visit_number,
33
+ "reason": reason,
34
+ "details": details,
35
+ "artifact_sha256": artifact_sha256,
36
+ "pipeline_sha256": pipeline_sha256,
37
+ "outcome": outcome,
38
+ "status": "pending",
39
+ "decision": None,
40
+ "actor": None,
41
+ "requested_at": requested_at,
42
+ "decided_at": None,
43
+ }
44
+
45
+
46
+ def approval_integrity_errors(
47
+ approval: ApprovalRecord, *, artifact_sha256: str, pipeline_sha256: str
48
+ ) -> list[str]:
49
+ """Return stable mismatch labels for an approval's bound inputs."""
50
+
51
+ errors: list[str] = []
52
+ if approval.get("artifact_sha256") != artifact_sha256:
53
+ errors.append("artifact")
54
+ if approval.get("pipeline_sha256") != pipeline_sha256:
55
+ errors.append("pipeline")
56
+ return errors
57
+
58
+
59
+ def pending_approval(manifest: RunRecord, visit_number: int) -> ApprovalRecord | None:
60
+ return latest_approval(manifest, "pending", visit_number=visit_number)
61
+
62
+
63
+ def approved_record(manifest: RunRecord, visit_number: int) -> ApprovalRecord | None:
64
+ return latest_approval(manifest, "approved", visit_number=visit_number)
65
+
66
+
67
+ def approved_for_phase(manifest: RunRecord, phase_id: str) -> ApprovalRecord | None:
68
+ return next(
69
+ (
70
+ item
71
+ for item in reversed(manifest["approvals"])
72
+ if item.get("phase_id") == phase_id and item.get("status") == "approved"
73
+ ),
74
+ None,
75
+ )
76
+
77
+
78
+ def requested_state(
79
+ manifest: RunRecord,
80
+ approval: ApprovalRecord,
81
+ *,
82
+ validation_checks: list[dict[str, Any]],
83
+ command_results: list[dict[str, Any]],
84
+ mutation_result: dict[str, Any] | None,
85
+ ) -> RunRecord:
86
+ """Return a detached pending state using already collected evidence."""
87
+
88
+ updated = copy.deepcopy(manifest)
89
+ current = current_visit(updated)
90
+ assert current is not None
91
+ current["status"] = "awaiting-approval"
92
+ current["validation_checks"] = copy.deepcopy(validation_checks)
93
+ current["mutation_result"] = copy.deepcopy(mutation_result)
94
+ current["command_results"] = copy.deepcopy(command_results)
95
+ updated["status"] = "awaiting-approval"
96
+ updated["approvals"].append(copy.deepcopy(approval))
97
+ return updated
98
+
99
+
100
+ IDENTITY_FIELDS = (
101
+ "run_id", "visit_number", "phase_id", "reason", "details", "requested_at",
102
+ "outcome", "pipeline_sha256", "artifact_sha256",
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class ApprovalDecision:
108
+ """A detached approval result; absent event means no durable change."""
109
+
110
+ manifest: RunRecord
111
+ approval: ApprovalRecord
112
+ disposition: str
113
+ event_type: str | None = None
114
+ visit_number: int | None = None
115
+ payload: dict[str, Any] = field(default_factory=dict)
116
+
117
+
118
+ def conflict(observed: ApprovalRecord) -> EngineError:
119
+ return EngineError(
120
+ ExitCode.ILLEGAL_TRANSITION,
121
+ "approval state changed before the command could be applied",
122
+ "approval-state-changed",
123
+ run_id=observed.get("run_id"), phase=observed.get("phase_id"),
124
+ visit_number=observed.get("visit_number"),
125
+ )
126
+
127
+
128
+ def require_binding(manifest: RunRecord, observed: ApprovalRecord) -> VisitRecord:
129
+ """Reject incomplete identities or a mismatched containing run/visit."""
130
+
131
+ if any(key not in observed for key in IDENTITY_FIELDS):
132
+ raise conflict(observed)
133
+ visits = [v for v in manifest["visits"]
134
+ if v["ordinal"] == observed["visit_number"]]
135
+ if (manifest["run_id"] != observed["run_id"] or len(visits) != 1
136
+ or visits[0]["phase_id"] != observed["phase_id"]):
137
+ raise conflict(observed)
138
+ return visits[0]
139
+
140
+
141
+ def _matches(
142
+ record: ApprovalRecord, observed: ApprovalRecord, *, creation: bool = False,
143
+ ) -> bool:
144
+ return all(key in record and record[key] == observed[key]
145
+ for key in IDENTITY_FIELDS if not (creation and key == "requested_at"))
146
+
147
+
148
+ def _eligible(
149
+ manifest: RunRecord, visit: VisitRecord, *, creation: bool = False,
150
+ ) -> bool:
151
+ statuses = ("active", "awaiting-approval") if creation else ("awaiting-approval",)
152
+ return (manifest["current_visit"] == visit["ordinal"]
153
+ and manifest["status"] in statuses and visit["status"] in statuses)
154
+
155
+
156
+ def request_decision(
157
+ manifest: RunRecord, approval: ApprovalRecord, *,
158
+ validation_checks: list[dict[str, Any]], command_results: list[dict[str, Any]],
159
+ mutation_result: dict[str, Any] | None,
160
+ ) -> ApprovalDecision:
161
+ """Create once, or reuse the winning pending request without new evidence."""
162
+
163
+ visit = require_binding(manifest, approval)
164
+ if not _eligible(manifest, visit, creation=True):
165
+ raise conflict(approval)
166
+ records = [a for a in manifest["approvals"]
167
+ if a.get("visit_number") == visit["ordinal"]
168
+ and a.get("status") in ("pending", "approved")]
169
+ if records:
170
+ if (len(records) != 1 or records[0].get("status") != "pending"
171
+ or any(key not in records[0] for key in IDENTITY_FIELDS)
172
+ or not _matches(records[0], approval, creation=True)):
173
+ raise conflict(approval)
174
+ return ApprovalDecision(copy.deepcopy(manifest), copy.deepcopy(records[0]), "pending")
175
+ updated = requested_state(
176
+ manifest, approval, validation_checks=validation_checks,
177
+ command_results=command_results, mutation_result=mutation_result,
178
+ )
179
+ return ApprovalDecision(
180
+ updated, copy.deepcopy(approval), "requested", "approval-requested",
181
+ visit["ordinal"], {"reason": approval["reason"], "outcome": approval["outcome"]},
182
+ )
183
+
184
+
185
+ def _identified(
186
+ manifest: RunRecord, observed: ApprovalRecord,
187
+ ) -> tuple[VisitRecord, ApprovalRecord]:
188
+ visit = require_binding(manifest, observed)
189
+ records = [a for a in manifest["approvals"] if _matches(a, observed)]
190
+ # A replacement on the same visit must not inherit an earlier decision.
191
+ competing = [a for a in manifest["approvals"]
192
+ if a.get("visit_number") == visit["ordinal"]
193
+ and a.get("status") in ("pending", "approved")
194
+ and not _matches(a, observed)]
195
+ if len(records) != 1 or competing:
196
+ raise conflict(observed)
197
+ record = records[0]
198
+ if any(a.get("visit_number") == visit["ordinal"]
199
+ for a in manifest["approvals"][manifest["approvals"].index(record) + 1:]):
200
+ raise conflict(observed)
201
+ return visit, record
202
+
203
+
204
+ def grant_decision(
205
+ manifest: RunRecord, observed: ApprovalRecord, *,
206
+ transition_target: str | None, decided_at: str | None,
207
+ ) -> ApprovalDecision:
208
+ """Grant exactly the observed request; None permits only existing grants."""
209
+
210
+ updated = copy.deepcopy(manifest)
211
+ visit, record = _identified(updated, observed)
212
+ if record.get("status") == "approved" and visit["status"] == "completed":
213
+ if (visit["chosen_outcome"] != record["outcome"]
214
+ or visit["transition_target"] != transition_target):
215
+ raise conflict(observed)
216
+ return ApprovalDecision(updated, copy.deepcopy(record), "completed")
217
+ if not _eligible(updated, visit):
218
+ raise conflict(observed)
219
+ if record.get("status") == "approved":
220
+ return ApprovalDecision(updated, copy.deepcopy(record), "granted")
221
+ if record.get("status") != "pending" or decided_at is None:
222
+ raise conflict(observed)
223
+ record.update(status="approved", decision="approved", actor="user", decided_at=decided_at)
224
+ return ApprovalDecision(
225
+ updated, copy.deepcopy(record), "granted", "approval-granted",
226
+ visit["ordinal"], {"phase_id": visit["phase_id"], "actor": "user"},
227
+ )
228
+
229
+
230
+ def invalidate_decision(
231
+ manifest: RunRecord, observed: ApprovalRecord, *, decided_at: str,
232
+ mismatches: list[str],
233
+ ) -> ApprovalDecision:
234
+ """Invalidate only the eligible observed request, including stale retries."""
235
+
236
+ updated = copy.deepcopy(manifest)
237
+ visit, record = _identified(updated, observed)
238
+ if not _eligible(updated, visit):
239
+ raise conflict(observed)
240
+ if record.get("status") == "stale" and record.get("decision") == "invalidated":
241
+ return ApprovalDecision(updated, copy.deepcopy(record), "stale")
242
+ if record.get("status") not in ("pending", "approved"):
243
+ raise conflict(observed)
244
+ record.update(status="stale", decision="invalidated", actor="system", decided_at=decided_at)
245
+ return ApprovalDecision(
246
+ updated, copy.deepcopy(record), "stale", "approval-invalidated",
247
+ visit["ordinal"], {"mismatches": list(mismatches)},
248
+ )
@@ -0,0 +1,81 @@
1
+ """Shared, pure command argument resolution and transition diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._config.config_models import PhaseConfig
8
+ from .engine_errors import EngineError
9
+ from ..errors import UsageError
10
+ from ..exit_codes import ExitCode
11
+ from .._runs.run_records import RunRecord
12
+
13
+
14
+ def select_outcome(phase: PhaseConfig, requested: str | None) -> str:
15
+ outcomes = [
16
+ transition.outcome
17
+ for transition in phase.transitions
18
+ if transition.outcome != "blocked"
19
+ ]
20
+ if requested is not None:
21
+ if requested not in outcomes:
22
+ raise EngineError(
23
+ ExitCode.ILLEGAL_TRANSITION,
24
+ f"outcome {requested!r} is not declared by phase {phase.id!r}",
25
+ "undeclared-outcome",
26
+ phase=phase.id,
27
+ requested=requested,
28
+ declared=outcomes,
29
+ )
30
+ if len(outcomes) <= 1:
31
+ raise EngineError(
32
+ ExitCode.ILLEGAL_TRANSITION,
33
+ "--outcome is valid only when the phase has multiple successful outcomes",
34
+ "unnecessary-outcome",
35
+ phase=phase.id,
36
+ declared=outcomes,
37
+ )
38
+ return requested
39
+ if len(outcomes) != 1:
40
+ raise EngineError(
41
+ ExitCode.ILLEGAL_TRANSITION,
42
+ f"phase {phase.id!r} requires one of {outcomes!r}",
43
+ "outcome-required",
44
+ phase=phase.id,
45
+ declared=outcomes,
46
+ )
47
+ return outcomes[0]
48
+
49
+
50
+ def declared_reason(
51
+ requested: str | None, declared: tuple[str, ...], kind: str
52
+ ) -> str:
53
+ if requested is None:
54
+ if len(declared) == 1:
55
+ return declared[0]
56
+ raise UsageError(
57
+ f"--reason is required; declared {kind} reasons: {', '.join(declared) or 'none'}",
58
+ {"declared": list(declared)},
59
+ )
60
+ if requested not in declared:
61
+ raise EngineError(
62
+ ExitCode.ILLEGAL_TRANSITION,
63
+ f"{kind} reason {requested!r} is not declared",
64
+ f"undeclared-{kind}-reason",
65
+ requested=requested,
66
+ declared=list(declared),
67
+ )
68
+ return requested
69
+
70
+
71
+ def illegal_transition(
72
+ message: str, manifest: RunRecord, **details: Any
73
+ ) -> EngineError:
74
+ return EngineError(
75
+ ExitCode.ILLEGAL_TRANSITION,
76
+ message,
77
+ "illegal-transition",
78
+ status=manifest["status"],
79
+ **details,
80
+ )
81
+
@@ -0,0 +1,22 @@
1
+ """Expected engine rejections, independent of command orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..errors import SpecromancyError
8
+ from ..exit_codes import ExitCode
9
+
10
+
11
+ class EngineError(SpecromancyError):
12
+ """An expected state-machine rejection with a stable diagnostic."""
13
+
14
+ def __init__(
15
+ self,
16
+ code: ExitCode,
17
+ message: str,
18
+ diagnostic_code: str,
19
+ **details: Any,
20
+ ) -> None:
21
+ super().__init__(code, message, {"error_code": diagnostic_code, **details})
22
+ self.diagnostic_code = diagnostic_code
@@ -0,0 +1,58 @@
1
+ """Observe pipeline and prepared resource identity without choosing a policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .._config.config_models import PipelineConfig
8
+ from ..hashing import relative_path, sha256_file
9
+ from .._runs.run_records import RunRecord
10
+
11
+
12
+ def observe_provenance(
13
+ pipeline: PipelineConfig, manifest: RunRecord
14
+ ) -> list[dict[str, Any]]:
15
+ warnings: list[dict[str, Any]] = []
16
+ for visit in manifest["visits"]:
17
+ for kind in ("skill", "template"):
18
+ record = visit[kind]
19
+ if record is None:
20
+ continue
21
+ path = pipeline.repository_root / record["path"]
22
+ try:
23
+ actual = sha256_file(path)
24
+ except OSError as exc:
25
+ warnings.append(
26
+ {
27
+ "code": "provenance-missing",
28
+ "message": f"visit {visit['ordinal']} {kind} provenance is missing",
29
+ "details": {"path": record["path"], "error": str(exc)},
30
+ }
31
+ )
32
+ continue
33
+ if actual != record["sha256"]:
34
+ warnings.append(
35
+ {
36
+ "code": "provenance-hash-mismatch",
37
+ "message": f"visit {visit['ordinal']} {kind} changed after preparation",
38
+ "details": {
39
+ "path": record["path"],
40
+ "expected": record["sha256"],
41
+ "actual": actual,
42
+ },
43
+ }
44
+ )
45
+ return warnings
46
+
47
+
48
+ def pipeline_matches(pipeline: PipelineConfig, manifest: RunRecord) -> bool:
49
+ return manifest["pipeline"] == pipeline_identity(pipeline)
50
+
51
+
52
+ def pipeline_identity(pipeline: PipelineConfig) -> dict[str, Any]:
53
+ return {
54
+ "id": pipeline.id,
55
+ "version": pipeline.version,
56
+ "path": relative_path(pipeline.path, pipeline.repository_root),
57
+ "sha256": pipeline.config_hash,
58
+ }