devflow-toolkit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. devflow/__init__.py +0 -0
  2. devflow/adapters/__init__.py +47 -0
  3. devflow/adapters/agents.py +49 -0
  4. devflow/adapters/base.py +81 -0
  5. devflow/adapters/claude_code.py +120 -0
  6. devflow/adapters/codex.py +111 -0
  7. devflow/adapters/detector.py +118 -0
  8. devflow/adapters/instructions.py +263 -0
  9. devflow/adapters/kiro.py +211 -0
  10. devflow/adapters/opencode.py +237 -0
  11. devflow/adapters/registry.py +41 -0
  12. devflow/cli.py +39 -0
  13. devflow/commands/__init__.py +6 -0
  14. devflow/commands/approve.py +98 -0
  15. devflow/commands/harness.py +98 -0
  16. devflow/commands/init.py +544 -0
  17. devflow/commands/spec.py +514 -0
  18. devflow/commands/status.py +156 -0
  19. devflow/core/__init__.py +15 -0
  20. devflow/core/architecture.py +469 -0
  21. devflow/core/artifacts/__init__.py +29 -0
  22. devflow/core/artifacts/errors.py +19 -0
  23. devflow/core/artifacts/models.py +82 -0
  24. devflow/core/artifacts/store.py +130 -0
  25. devflow/core/clock.py +67 -0
  26. devflow/core/config.py +706 -0
  27. devflow/core/constitution.py +166 -0
  28. devflow/core/discovery.py +1304 -0
  29. devflow/core/ports.py +159 -0
  30. devflow/core/profile_manager.py +311 -0
  31. devflow/core/project.py +533 -0
  32. devflow/core/spec_workflow.py +932 -0
  33. devflow/core/specification.py +2443 -0
  34. devflow/core/state/__init__.py +75 -0
  35. devflow/core/state/errors.py +39 -0
  36. devflow/core/state/events.py +725 -0
  37. devflow/core/state/locator.py +116 -0
  38. devflow/core/state/lock.py +69 -0
  39. devflow/core/state/manager.py +960 -0
  40. devflow/core/state/models.py +164 -0
  41. devflow/core/state/transitions.py +114 -0
  42. devflow/models/__init__.py +6 -0
  43. devflow/models/profile.py +63 -0
  44. devflow/models/project.py +47 -0
  45. devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
  46. devflow/profiles/ai-rag/profile.yaml +28 -0
  47. devflow/profiles/base/constitution/architecture.md +58 -0
  48. devflow/profiles/base/constitution/code-standards.md +85 -0
  49. devflow/profiles/base/constitution/constitution.md +85 -0
  50. devflow/profiles/base/profile.yaml +15 -0
  51. devflow/profiles/django-backend/constitution/django-standards.md +64 -0
  52. devflow/profiles/django-backend/profile.yaml +24 -0
  53. devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
  54. devflow/profiles/fastapi-backend/profile.yaml +23 -0
  55. devflow/prompts/__init__.py +200 -0
  56. devflow/prompts/discovery.md +119 -0
  57. devflow/prompts/spec.greenfield.md +149 -0
  58. devflow/prompts/spec.md +116 -0
  59. devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
  60. devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
  61. devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
  62. devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
  63. devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,725 @@
1
+ """Append-only workflow event log and its state projection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass, field, replace
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from devflow.core.artifacts.models import (
14
+ ARTIFACT_SOURCES,
15
+ ArtifactRecord,
16
+ ArtifactStatus,
17
+ )
18
+ from devflow.core.state.errors import (
19
+ InvalidEventLogError,
20
+ InvalidTransitionError,
21
+ )
22
+ from devflow.core.state.models import (
23
+ STAGE_ARTIFACTS,
24
+ STAGE_OPTIONAL_ARTIFACTS,
25
+ STAGE_TARGETS,
26
+ WORKFLOW_STATUSES,
27
+ ApprovalRecord,
28
+ WorkflowState,
29
+ WorkflowStatus,
30
+ )
31
+ from devflow.core.state.transitions import ensure_transition
32
+
33
+ WORKFLOW_INITIALIZED = "workflow_initialized"
34
+ WORKFLOW_TRANSITION = "workflow_transition"
35
+
36
+ WORKFLOW_EVENTS = frozenset({WORKFLOW_INITIALIZED, WORKFLOW_TRANSITION})
37
+ ARTIFACT_EVENT_SUFFIXES = frozenset(
38
+ {"generated", "validated", "rejected", "stale"}
39
+ )
40
+ APPROVAL_EVENT_SUFFIX = "approved"
41
+
42
+ _EVENT_KIND_WORKFLOW = "workflow"
43
+ _EVENT_KIND_ARTIFACT = "artifact"
44
+ _EVENT_KIND_APPROVAL = "approval"
45
+
46
+ _EVENT_STATUSES = {
47
+ "generated": ArtifactStatus.GENERATED.value,
48
+ "validated": ArtifactStatus.VALIDATED.value,
49
+ "rejected": ArtifactStatus.REJECTED.value,
50
+ "stale": ArtifactStatus.STALE.value,
51
+ }
52
+ _SHA256_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class WorkflowEvent:
57
+ """One immutable entry in events.jsonl."""
58
+
59
+ seq: int
60
+ event_id: str
61
+ type: str
62
+ timestamp: str
63
+ actor: str
64
+ run_id: str | None = None
65
+ payload: dict[str, Any] = field(default_factory=dict)
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "seq": self.seq,
70
+ "event_id": self.event_id,
71
+ "type": self.type,
72
+ "timestamp": self.timestamp,
73
+ "actor": self.actor,
74
+ "run_id": self.run_id,
75
+ "payload": dict(self.payload),
76
+ }
77
+
78
+ def to_json(self) -> str:
79
+ return json.dumps(
80
+ self.to_dict(),
81
+ ensure_ascii=False,
82
+ separators=(",", ":"),
83
+ )
84
+
85
+ @classmethod
86
+ def from_dict(cls, data: dict[str, Any]) -> WorkflowEvent:
87
+ if not isinstance(data, dict):
88
+ msg = "Workflow event must be a mapping."
89
+ raise InvalidEventLogError(msg)
90
+
91
+ payload_value = data.get("payload", {})
92
+ if payload_value is None:
93
+ payload: dict[str, Any] = {}
94
+ elif isinstance(payload_value, dict):
95
+ payload = payload_value
96
+ else:
97
+ msg = "Workflow event payload must be a mapping."
98
+ raise InvalidEventLogError(msg)
99
+
100
+ return cls(
101
+ seq=_positive_int(data.get("seq"), "seq"),
102
+ event_id=_required_text(data.get("event_id"), "event_id"),
103
+ type=_required_text(data.get("type"), "type"),
104
+ timestamp=_required_text(data.get("timestamp"), "timestamp"),
105
+ actor=_required_text(data.get("actor", "system"), "actor"),
106
+ run_id=_optional_text(data.get("run_id"), "run_id"),
107
+ payload=payload,
108
+ )
109
+
110
+
111
+ def read_events(path: Path) -> tuple[WorkflowEvent, ...]:
112
+ """Read and validate the structural invariants of the event log."""
113
+ if not path.is_file():
114
+ return ()
115
+ events: list[WorkflowEvent] = []
116
+ try:
117
+ lines = path.read_text(encoding="utf-8").splitlines()
118
+ except OSError as error:
119
+ msg = f"Could not read event log: {path}"
120
+ raise InvalidEventLogError(msg) from error
121
+
122
+ seen_ids: dict[str, int] = {}
123
+ for number, line in enumerate(lines, start=1):
124
+ if not line.strip():
125
+ continue
126
+ try:
127
+ data = json.loads(line)
128
+ except json.JSONDecodeError as error:
129
+ msg = f"Invalid JSON in event log at line {number}: {path}"
130
+ raise InvalidEventLogError(msg) from error
131
+ if not isinstance(data, dict):
132
+ msg = f"Event log line {number} must contain an object: {path}"
133
+ raise InvalidEventLogError(msg)
134
+ try:
135
+ event = WorkflowEvent.from_dict(data)
136
+ classify_event(event.type)
137
+ except InvalidEventLogError as error:
138
+ msg = f"Invalid event at line {number}: {error}"
139
+ raise InvalidEventLogError(msg) from error
140
+ except (KeyError, TypeError, ValueError) as error:
141
+ msg = f"Invalid event at line {number}: {path}"
142
+ raise InvalidEventLogError(msg) from error
143
+
144
+ if event.event_id in seen_ids:
145
+ first_seq = seen_ids[event.event_id]
146
+ msg = (
147
+ f"Duplicate event_id '{event.event_id}' at line {number} "
148
+ f"(seq {event.seq}); first seen at seq {first_seq}: {path}"
149
+ )
150
+ raise InvalidEventLogError(msg)
151
+ seen_ids[event.event_id] = event.seq
152
+ events.append(event)
153
+
154
+ expected = 1
155
+ for event in events:
156
+ if event.seq != expected:
157
+ msg = (
158
+ f"Event sequence is not contiguous at event {event.event_id}: "
159
+ f"expected {expected}, found {event.seq}."
160
+ )
161
+ raise InvalidEventLogError(msg)
162
+ expected += 1
163
+
164
+ if events and events[0].type != WORKFLOW_INITIALIZED:
165
+ msg = (
166
+ "Event log must start with "
167
+ f"{WORKFLOW_INITIALIZED}, found {events[0].type}."
168
+ )
169
+ raise InvalidEventLogError(msg)
170
+ for event in events[1:]:
171
+ if event.type == WORKFLOW_INITIALIZED:
172
+ msg = (
173
+ f"Duplicate {WORKFLOW_INITIALIZED} event at seq "
174
+ f"{event.seq} ({event.event_id})."
175
+ )
176
+ raise InvalidEventLogError(msg)
177
+ return tuple(events)
178
+
179
+
180
+ def classify_event(
181
+ event_type: str,
182
+ ) -> tuple[str, str | None, str | None]:
183
+ """Classify an event without applying it to workflow state."""
184
+ if event_type in WORKFLOW_EVENTS:
185
+ return _EVENT_KIND_WORKFLOW, None, None
186
+
187
+ stage, separator, suffix = event_type.rpartition("_")
188
+ if separator and stage in STAGE_ARTIFACTS:
189
+ if suffix in ARTIFACT_EVENT_SUFFIXES:
190
+ return _EVENT_KIND_ARTIFACT, stage, suffix
191
+ if suffix == APPROVAL_EVENT_SUFFIX:
192
+ return _EVENT_KIND_APPROVAL, stage, suffix
193
+
194
+ msg = f"Unknown workflow event type: {event_type}"
195
+ raise InvalidEventLogError(msg)
196
+
197
+
198
+ def replay_events(events: Iterable[WorkflowEvent]) -> WorkflowState:
199
+ """Replay a complete event history into a new workflow state."""
200
+ ordered = tuple(events)
201
+ if ordered and ordered[0].type != WORKFLOW_INITIALIZED:
202
+ raise _event_error(
203
+ ordered[0],
204
+ f"the first event must be {WORKFLOW_INITIALIZED}",
205
+ )
206
+ state = WorkflowState()
207
+ for event in ordered:
208
+ apply_event(state, event)
209
+ return state
210
+
211
+
212
+ def append_event(path: Path, event: WorkflowEvent) -> None:
213
+ """Append one event durably."""
214
+ path.parent.mkdir(parents=True, exist_ok=True)
215
+ line = event.to_json() + "\n"
216
+ try:
217
+ descriptor = os.open(
218
+ path,
219
+ os.O_APPEND | os.O_CREAT | os.O_WRONLY,
220
+ 0o644,
221
+ )
222
+ try:
223
+ data = line.encode("utf-8")
224
+ while data:
225
+ written = os.write(descriptor, data)
226
+ data = data[written:]
227
+ os.fsync(descriptor)
228
+ finally:
229
+ os.close(descriptor)
230
+ except OSError as error:
231
+ msg = f"Could not append to event log: {path}"
232
+ raise InvalidEventLogError(msg) from error
233
+
234
+
235
+ def apply_event(state: WorkflowState, event: WorkflowEvent) -> WorkflowState:
236
+ """Validate and apply one event to a workflow projection."""
237
+ expected = state.last_event_seq + 1
238
+ if event.seq != expected:
239
+ raise _event_error(
240
+ event,
241
+ f"expected sequence {expected}, found {event.seq}",
242
+ )
243
+
244
+ try:
245
+ kind, stage, suffix = classify_event(event.type)
246
+ except InvalidEventLogError as error:
247
+ raise _event_error(event, str(error)) from error
248
+
249
+ if kind == _EVENT_KIND_WORKFLOW:
250
+ if event.type == WORKFLOW_INITIALIZED:
251
+ _apply_initialized(state, event)
252
+ else:
253
+ _apply_transition(state, event)
254
+ elif kind == _EVENT_KIND_ARTIFACT:
255
+ if stage is None or suffix is None:
256
+ raise _event_error(event, "artifact event is missing its stage.")
257
+ _apply_artifact_event(state, event, stage, suffix)
258
+ else:
259
+ if stage is None:
260
+ raise _event_error(event, "approval event is missing its stage.")
261
+ _apply_approval(state, event, stage)
262
+
263
+ state.last_event_seq = event.seq
264
+ state.updated_at = event.timestamp
265
+ return state
266
+
267
+
268
+ def _apply_initialized(
269
+ state: WorkflowState,
270
+ event: WorkflowEvent,
271
+ ) -> None:
272
+ payload = event.payload
273
+ version_value = payload.get("version", 1)
274
+ if (
275
+ isinstance(version_value, bool)
276
+ or not isinstance(version_value, int)
277
+ or version_value != 1
278
+ ):
279
+ raise _event_error(event, "only workflow version 1 is supported.")
280
+
281
+ status_value = payload.get("status", WorkflowStatus.DRAFT.value)
282
+ if status_value != WorkflowStatus.DRAFT.value:
283
+ raise _event_error(
284
+ event,
285
+ "workflow_initialized must start in draft.",
286
+ )
287
+
288
+ if state.last_event_seq != 0:
289
+ raise _event_error(
290
+ event,
291
+ "workflow_initialized must be the first event.",
292
+ )
293
+
294
+ spec_id = payload.get("spec_id", state.spec_id)
295
+ change = payload.get("change", state.change)
296
+ created_at = payload.get("created_at", event.timestamp)
297
+ if not isinstance(spec_id, str):
298
+ raise _event_error(event, "spec_id must be a string.")
299
+ if not isinstance(change, str):
300
+ raise _event_error(event, "change must be a string.")
301
+ if not isinstance(created_at, str) or not created_at:
302
+ raise _event_error(event, "created_at must be a non-empty string.")
303
+
304
+ state.version = version_value
305
+ state.spec_id = spec_id
306
+ state.change = change
307
+ state.created_at = created_at
308
+ state.status = status_value
309
+
310
+
311
+ def _apply_transition(
312
+ state: WorkflowState,
313
+ event: WorkflowEvent,
314
+ ) -> None:
315
+ target_value = event.payload.get("to")
316
+ if not isinstance(target_value, str) or target_value not in WORKFLOW_STATUSES:
317
+ raise _event_error(
318
+ event,
319
+ f"unknown workflow target: {target_value!r}.",
320
+ )
321
+ try:
322
+ ensure_transition(state.status, target_value)
323
+ except InvalidTransitionError as error:
324
+ raise _event_error(
325
+ event,
326
+ f"invalid workflow transition {state.status} -> {target_value}.",
327
+ ) from error
328
+ state.status = target_value
329
+
330
+
331
+ def _apply_artifact_event(
332
+ state: WorkflowState,
333
+ event: WorkflowEvent,
334
+ stage: str,
335
+ suffix: str,
336
+ ) -> None:
337
+ payload = event.payload
338
+ payload_stage = payload.get("stage")
339
+ if payload_stage != stage:
340
+ raise _event_error(
341
+ event,
342
+ f"payload stage {payload_stage!r} does not match event type.",
343
+ )
344
+
345
+ kind_value = payload.get("kind", stage)
346
+ if (
347
+ not isinstance(kind_value, str)
348
+ or kind_value not in STAGE_ARTIFACTS[stage]
349
+ ):
350
+ raise _event_error(
351
+ event,
352
+ f"artifact kind {kind_value!r} does not belong to {stage}.",
353
+ )
354
+ kind = kind_value
355
+ previous = state.artifacts.get(kind)
356
+
357
+ path_value = payload.get("path", "")
358
+ if path_value is None:
359
+ path_value = ""
360
+ if not isinstance(path_value, str):
361
+ raise _event_error(event, "artifact path must be a string.")
362
+ path = path_value
363
+
364
+ digest_value = payload.get("digest", "")
365
+ if not isinstance(digest_value, str):
366
+ raise _event_error(event, "artifact digest must be a string.")
367
+ digest = _validated_digest(
368
+ event,
369
+ digest_value,
370
+ allow_empty=suffix == ArtifactStatus.STALE.value,
371
+ )
372
+
373
+ source_value = payload.get(
374
+ "source",
375
+ previous.source if previous is not None else "system",
376
+ )
377
+ if (
378
+ not isinstance(source_value, str)
379
+ or source_value not in ARTIFACT_SOURCES
380
+ ):
381
+ raise _event_error(
382
+ event,
383
+ f"unknown artifact source: {source_value!r}.",
384
+ )
385
+
386
+ metadata_value = payload.get("metadata", {})
387
+ if metadata_value is None:
388
+ metadata_value = {}
389
+ if not isinstance(metadata_value, dict):
390
+ raise _event_error(event, "artifact metadata must be a mapping.")
391
+
392
+ version_value = payload.get("version", 0)
393
+ if (
394
+ isinstance(version_value, bool)
395
+ or not isinstance(version_value, int)
396
+ or version_value < 0
397
+ ):
398
+ raise _event_error(event, "artifact version must be a positive integer.")
399
+
400
+ if suffix == ArtifactStatus.STALE.value:
401
+ _apply_stale_event(
402
+ state,
403
+ event,
404
+ kind=kind,
405
+ digest=digest,
406
+ previous=previous,
407
+ )
408
+ return
409
+
410
+ if not path:
411
+ raise _event_error(event, "artifact event is missing its path.")
412
+
413
+ if suffix == ArtifactStatus.GENERATED.value:
414
+ if previous is not None and previous.status == ArtifactStatus.APPROVED.value:
415
+ raise _event_error(
416
+ event,
417
+ f"artifact {kind!r} must be marked stale before regeneration.",
418
+ )
419
+ elif suffix == ArtifactStatus.VALIDATED.value:
420
+ if previous is None:
421
+ raise _event_error(
422
+ event,
423
+ f"cannot validate unknown artifact {kind!r}.",
424
+ )
425
+ if previous.digest != digest:
426
+ raise _event_error(
427
+ event,
428
+ f"validated digest for {kind!r} does not match the generated digest.",
429
+ )
430
+ if previous.status not in {
431
+ ArtifactStatus.GENERATED.value,
432
+ ArtifactStatus.VALIDATED.value,
433
+ }:
434
+ raise _event_error(
435
+ event,
436
+ f"artifact {kind!r} is {previous.status}, not generated.",
437
+ )
438
+ elif suffix == ArtifactStatus.REJECTED.value:
439
+ if previous is None:
440
+ raise _event_error(
441
+ event,
442
+ f"cannot reject unknown artifact {kind!r}.",
443
+ )
444
+ if previous.digest != digest:
445
+ raise _event_error(
446
+ event,
447
+ f"rejected digest for {kind!r} does not match the current digest.",
448
+ )
449
+ if previous.status not in {
450
+ ArtifactStatus.GENERATED.value,
451
+ ArtifactStatus.VALIDATED.value,
452
+ }:
453
+ raise _event_error(
454
+ event,
455
+ f"artifact {kind!r} is {previous.status} and cannot be rejected.",
456
+ )
457
+
458
+ version = version_value
459
+ if version < 1:
460
+ if previous is None:
461
+ version = 1
462
+ elif previous.digest != digest:
463
+ version = previous.version + 1
464
+ else:
465
+ version = previous.version
466
+
467
+ status = _EVENT_STATUSES[suffix]
468
+ record = ArtifactRecord(
469
+ kind=kind,
470
+ path=path,
471
+ digest=digest,
472
+ status=status,
473
+ source=source_value,
474
+ version=version,
475
+ updated_at=event.timestamp,
476
+ validated_at=(
477
+ event.timestamp
478
+ if status == ArtifactStatus.VALIDATED.value
479
+ else ""
480
+ ),
481
+ approved_at="",
482
+ stale_at="",
483
+ metadata={
484
+ **(previous.metadata if previous is not None else {}),
485
+ **metadata_value,
486
+ },
487
+ )
488
+ state.artifacts[kind] = record
489
+ if suffix in {
490
+ ArtifactStatus.GENERATED.value,
491
+ ArtifactStatus.VALIDATED.value,
492
+ ArtifactStatus.REJECTED.value,
493
+ }:
494
+ state.approvals.pop(stage, None)
495
+
496
+
497
+ def _apply_stale_event(
498
+ state: WorkflowState,
499
+ event: WorkflowEvent,
500
+ *,
501
+ kind: str,
502
+ digest: str,
503
+ previous: ArtifactRecord | None,
504
+ ) -> None:
505
+ if previous is None:
506
+ raise _event_error(
507
+ event,
508
+ f"stale event for unknown artifact {kind!r}.",
509
+ )
510
+ if previous.status == ArtifactStatus.STALE.value:
511
+ raise _event_error(event, f"artifact {kind!r} is already stale.")
512
+
513
+ previous_digest_value = event.payload.get("previous_digest")
514
+ if previous_digest_value is not None:
515
+ if not isinstance(previous_digest_value, str):
516
+ raise _event_error(event, "previous_digest must be a string.")
517
+ previous_digest = _validated_digest(
518
+ event,
519
+ previous_digest_value,
520
+ field="previous_digest",
521
+ )
522
+ if previous_digest != previous.digest:
523
+ raise _event_error(
524
+ event,
525
+ f"previous_digest for {kind!r} does not match the current digest.",
526
+ )
527
+
528
+ state.artifacts[kind] = replace(
529
+ previous,
530
+ status=ArtifactStatus.STALE.value,
531
+ stale_at=event.timestamp,
532
+ updated_at=event.timestamp,
533
+ metadata={
534
+ **previous.metadata,
535
+ "observed_digest": digest,
536
+ },
537
+ )
538
+
539
+
540
+ def _apply_approval(
541
+ state: WorkflowState,
542
+ event: WorkflowEvent,
543
+ stage: str,
544
+ ) -> None:
545
+ payload = event.payload
546
+ payload_stage = payload.get("stage")
547
+ if payload_stage != stage:
548
+ raise _event_error(
549
+ event,
550
+ f"payload stage {payload_stage!r} does not match event type.",
551
+ )
552
+ if stage in state.approvals:
553
+ raise _event_error(event, f"stage {stage!r} is already approved.")
554
+
555
+ target_value = payload.get("workflow_status")
556
+ if not isinstance(target_value, str) or not target_value:
557
+ raise _event_error(
558
+ event,
559
+ "approval event is missing workflow_status.",
560
+ )
561
+ expected_target = STAGE_TARGETS.get(stage)
562
+ if target_value != expected_target:
563
+ raise _event_error(
564
+ event,
565
+ f"approval target {target_value!r} is not {expected_target!r}.",
566
+ )
567
+
568
+ artifacts_value = payload.get("artifacts")
569
+ if not isinstance(artifacts_value, dict) or not artifacts_value:
570
+ raise _event_error(
571
+ event,
572
+ f"approval event for {stage!r} has no artifact digests.",
573
+ )
574
+ required_kinds = set(STAGE_ARTIFACTS[stage])
575
+ optional_kinds = set(STAGE_OPTIONAL_ARTIFACTS.get(stage, ()))
576
+ actual_kinds = set(artifacts_value)
577
+ if not required_kinds <= actual_kinds:
578
+ missing = ", ".join(sorted(required_kinds - actual_kinds))
579
+ raise _event_error(
580
+ event,
581
+ f"approval for {stage!r} is missing required artifacts: "
582
+ f"[{missing}].",
583
+ )
584
+ unknown = actual_kinds - required_kinds - optional_kinds
585
+ if unknown:
586
+ actual = ", ".join(sorted(str(key) for key in unknown))
587
+ allowed = ", ".join(sorted(required_kinds | optional_kinds))
588
+ raise _event_error(
589
+ event,
590
+ f"approval for {stage!r} binds unknown artifacts [{actual}]; "
591
+ f"allowed kinds are [{allowed}].",
592
+ )
593
+
594
+ replacements: dict[str, ArtifactRecord] = {}
595
+ bound: dict[str, str] = {}
596
+ for kind in sorted(actual_kinds):
597
+ digest = _validated_digest(
598
+ event,
599
+ artifacts_value.get(kind),
600
+ field=f"artifacts[{kind}]",
601
+ )
602
+ record = state.artifacts.get(kind)
603
+ if record is None:
604
+ raise _event_error(
605
+ event,
606
+ f"approval references unknown artifact {kind!r}.",
607
+ )
608
+ if record.digest != digest:
609
+ raise _event_error(
610
+ event,
611
+ f"approval for {kind!r} does not match its validated digest.",
612
+ )
613
+ if record.status != ArtifactStatus.VALIDATED.value:
614
+ raise _event_error(
615
+ event,
616
+ f"artifact {kind!r} is {record.status}, not validated.",
617
+ )
618
+ replacements[kind] = replace(
619
+ record,
620
+ digest=digest,
621
+ status=ArtifactStatus.APPROVED.value,
622
+ approved_at=event.timestamp,
623
+ updated_at=event.timestamp,
624
+ stale_at="",
625
+ metadata={
626
+ key: value
627
+ for key, value in record.metadata.items()
628
+ if key != "observed_digest"
629
+ },
630
+ )
631
+ bound[kind] = digest
632
+
633
+ architecture_value = payload.get("architecture_digest", "")
634
+ if architecture_value is None:
635
+ architecture_value = ""
636
+ if not isinstance(architecture_value, str):
637
+ raise _event_error(
638
+ event,
639
+ "architecture_digest must be a SHA-256 digest or empty.",
640
+ )
641
+ architecture_digest = ""
642
+ if architecture_value:
643
+ architecture_digest = _validated_digest(
644
+ event,
645
+ architecture_value,
646
+ field="architecture_digest",
647
+ )
648
+
649
+ by_value = payload.get("by", event.actor)
650
+ if not isinstance(by_value, str) or not by_value.strip():
651
+ raise _event_error(event, "approval author must be a non-empty string.")
652
+
653
+ try:
654
+ ensure_transition(state.status, target_value)
655
+ except InvalidTransitionError as error:
656
+ raise _event_error(
657
+ event,
658
+ f"invalid workflow transition {state.status} -> {target_value}.",
659
+ ) from error
660
+
661
+ approval = ApprovalRecord(
662
+ stage=stage,
663
+ by=by_value,
664
+ at=event.timestamp,
665
+ artifacts=bound,
666
+ architecture_digest=architecture_digest,
667
+ )
668
+ for kind, record in replacements.items():
669
+ state.artifacts[kind] = record
670
+ state.approvals[stage] = approval
671
+ state.status = target_value
672
+
673
+
674
+ def _event_error(
675
+ event: WorkflowEvent,
676
+ reason: str,
677
+ ) -> InvalidEventLogError:
678
+ return InvalidEventLogError(
679
+ f"Invalid event {event.event_id} "
680
+ f"(seq {event.seq}, type {event.type}): {reason}"
681
+ )
682
+
683
+
684
+ def _validated_digest(
685
+ event: WorkflowEvent,
686
+ value: object,
687
+ *,
688
+ field: str = "digest",
689
+ allow_empty: bool = False,
690
+ ) -> str:
691
+ if not isinstance(value, str):
692
+ raise _event_error(event, f"{field} must be a SHA-256 digest.")
693
+ if allow_empty and value == "":
694
+ return ""
695
+ if not _SHA256_PATTERN.fullmatch(value):
696
+ raise _event_error(
697
+ event,
698
+ f"{field} is not a valid SHA-256 digest: {value!r}.",
699
+ )
700
+ return value
701
+
702
+
703
+ def _required_text(value: object, field: str) -> str:
704
+ if not isinstance(value, str) or not value.strip():
705
+ msg = (
706
+ f"Workflow event field '{field}' must be a non-empty string."
707
+ )
708
+ raise InvalidEventLogError(msg)
709
+ return value
710
+
711
+
712
+ def _optional_text(value: object, field: str) -> str | None:
713
+ if value is None:
714
+ return None
715
+ if not isinstance(value, str):
716
+ msg = f"Workflow event field '{field}' must be a string."
717
+ raise InvalidEventLogError(msg)
718
+ return value or None
719
+
720
+
721
+ def _positive_int(value: object, field: str) -> int:
722
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
723
+ msg = f"Workflow event field '{field}' must be a positive integer."
724
+ raise InvalidEventLogError(msg)
725
+ return value