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,469 @@
1
+ """Declared architecture manifests for Greenfield specifications.
2
+
3
+ DevFlow validates the structure and internal consistency of a manifest.
4
+ It does not certify that the declared architecture is appropriate.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from devflow.core.artifacts.errors import ArtifactError
16
+ from devflow.core.artifacts.store import ArtifactStore
17
+
18
+ ARCHITECTURE_RELATIVE_PATH = Path(".devflow") / "architecture.yaml"
19
+ SUPPORTED_MANIFEST_VERSIONS = (1,)
20
+ MAX_MANIFEST_BYTES = 256_000
21
+
22
+
23
+ class ArchitectureError(Exception):
24
+ """Raised when an architecture manifest is missing or invalid."""
25
+
26
+
27
+ ARCHITECTURE_TEMPLATE = """\
28
+ # DevFlow declared architecture manifest.
29
+ # Edit this file before running a Greenfield specification.
30
+ # DevFlow validates structure and internal consistency, not quality.
31
+ version: 1
32
+ style: ""
33
+ summary: ""
34
+ modules: []
35
+ layers: []
36
+ stack: {}
37
+ constraints: {}
38
+ quality_attributes: []
39
+ fitness_functions: []
40
+ """
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ArchitectureModule:
45
+ """A declared module or bounded context."""
46
+
47
+ name: str
48
+ responsibility: str
49
+ depends_on: tuple[str, ...] = ()
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ArchitectureLayer:
54
+ """A declared architectural layer."""
55
+
56
+ name: str
57
+ may_depend_on: tuple[str, ...] = ()
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ArchitectureManifest:
62
+ """A validated, reviewable declared architecture."""
63
+
64
+ path: Path
65
+ content: str
66
+ digest: str
67
+ version: int
68
+ style: str
69
+ summary: str
70
+ modules: tuple[ArchitectureModule, ...]
71
+ layers: tuple[ArchitectureLayer, ...]
72
+ stack: dict[str, tuple[str, ...]] = field(default_factory=dict)
73
+ constraints: dict[str, tuple[str, ...]] = field(default_factory=dict)
74
+ quality_attributes: tuple[str, ...] = ()
75
+ fitness_functions: tuple[str, ...] = ()
76
+ warnings: tuple[str, ...] = ()
77
+
78
+ def relative_path(self, root: Path) -> str:
79
+ """Return the manifest path relative to the project root."""
80
+ try:
81
+ return self.path.relative_to(root.resolve()).as_posix()
82
+ except ValueError:
83
+ return self.path.as_posix()
84
+
85
+ def render_appendix(self, root: Path) -> str:
86
+ """Render the declared manifest as a specification appendix."""
87
+ lines = [
88
+ "## Appendix A: Declared Architecture Manifest",
89
+ "",
90
+ "| Field | Value |",
91
+ "| --- | --- |",
92
+ f"| Manifest | `{self.relative_path(root)}` |",
93
+ f"| Digest | `{self.digest}` |",
94
+ f"| Version | `{self.version}` |",
95
+ f"| Style | {self.style} |",
96
+ "",
97
+ "The digest above identifies the exact manifest used to prepare "
98
+ "this specification. If the manifest changes, generate and approve "
99
+ "the specification again.",
100
+ "",
101
+ "```yaml",
102
+ self.content.rstrip(),
103
+ "```",
104
+ "",
105
+ ]
106
+ return "\n".join(lines)
107
+
108
+
109
+ class ArchitectureManifestLoader:
110
+ """Load and structurally validate `.devflow/architecture.yaml`."""
111
+
112
+ def load(
113
+ self,
114
+ root: Path,
115
+ *,
116
+ required: bool,
117
+ ) -> ArchitectureManifest | None:
118
+ """Load the manifest if present, or fail when it is required."""
119
+ project_root = root.resolve()
120
+ path = project_root / ARCHITECTURE_RELATIVE_PATH
121
+
122
+ if path.is_symlink():
123
+ msg = (
124
+ "Architecture manifest must not be a symbolic link: "
125
+ f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}"
126
+ )
127
+ raise ArchitectureError(msg)
128
+ if not path.is_file():
129
+ if required:
130
+ msg = (
131
+ "Greenfield mode requires a declared architecture manifest at "
132
+ f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}. Run 'devflow init "
133
+ "--project-mode greenfield' to create a template, then edit it."
134
+ )
135
+ raise ArchitectureError(msg)
136
+ return None
137
+
138
+ store = ArtifactStore(project_root)
139
+ try:
140
+ content = store.read(path)
141
+ digest = store.digest_text(content)
142
+ except ArtifactError as error:
143
+ raise ArchitectureError(str(error)) from error
144
+
145
+ if len(content.encode("utf-8")) > MAX_MANIFEST_BYTES:
146
+ msg = (
147
+ "Architecture manifest is too large. Keep it below "
148
+ f"{MAX_MANIFEST_BYTES} bytes."
149
+ )
150
+ raise ArchitectureError(msg)
151
+
152
+ try:
153
+ data = yaml.safe_load(content)
154
+ except yaml.YAMLError as error:
155
+ msg = (
156
+ "Invalid YAML in architecture manifest "
157
+ f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}: {error}"
158
+ )
159
+ raise ArchitectureError(msg) from error
160
+
161
+ if not isinstance(data, dict):
162
+ msg = "Architecture manifest must contain a mapping."
163
+ raise ArchitectureError(msg)
164
+
165
+ version = data.get("version")
166
+ if (
167
+ not isinstance(version, int)
168
+ or isinstance(version, bool)
169
+ or version not in SUPPORTED_MANIFEST_VERSIONS
170
+ ):
171
+ supported = ", ".join(
172
+ str(item) for item in SUPPORTED_MANIFEST_VERSIONS
173
+ )
174
+ msg = (
175
+ "architecture.version must be one of: "
176
+ f"{supported}. Found: {version!r}."
177
+ )
178
+ raise ArchitectureError(msg)
179
+
180
+ style = self._required_text(data.get("style"), "style")
181
+ summary = self._required_text(data.get("summary"), "summary")
182
+ modules = self._modules(data.get("modules"))
183
+ layers = self._layers(data.get("layers"))
184
+ stack = self._values_mapping(data.get("stack"), "stack")
185
+ constraints = self._values_mapping(
186
+ data.get("constraints"),
187
+ "constraints",
188
+ )
189
+ quality_attributes = self._text_list(
190
+ data.get("quality_attributes"),
191
+ "quality_attributes",
192
+ )
193
+ fitness_functions = self._text_list(
194
+ data.get("fitness_functions"),
195
+ "fitness_functions",
196
+ )
197
+
198
+ self._check_references(modules, layers)
199
+ warnings = self._cycles(modules, layers)
200
+
201
+ return ArchitectureManifest(
202
+ path=path,
203
+ content=content,
204
+ digest=digest,
205
+ version=version,
206
+ style=style,
207
+ summary=summary,
208
+ modules=modules,
209
+ layers=layers,
210
+ stack=stack,
211
+ constraints=constraints,
212
+ quality_attributes=quality_attributes,
213
+ fitness_functions=fitness_functions,
214
+ warnings=warnings,
215
+ )
216
+
217
+ def _required_text(self, value: Any, field_name: str) -> str:
218
+ if not isinstance(value, str) or not value.strip():
219
+ msg = f"architecture.{field_name} must be a non-empty string."
220
+ raise ArchitectureError(msg)
221
+ return value.strip()
222
+
223
+ def _text_list(self, value: Any, field_name: str) -> tuple[str, ...]:
224
+ if value is None:
225
+ return ()
226
+ if not isinstance(value, list):
227
+ msg = f"architecture.{field_name} must be a list of strings."
228
+ raise ArchitectureError(msg)
229
+ items: list[str] = []
230
+ for item in value:
231
+ if not isinstance(item, str) or not item.strip():
232
+ msg = (
233
+ f"architecture.{field_name} entries must be non-empty "
234
+ "strings."
235
+ )
236
+ raise ArchitectureError(msg)
237
+ items.append(item.strip())
238
+ return tuple(items)
239
+
240
+ def _values_mapping(
241
+ self,
242
+ value: Any,
243
+ field_name: str,
244
+ ) -> dict[str, tuple[str, ...]]:
245
+ if value is None:
246
+ return {}
247
+ if not isinstance(value, dict):
248
+ msg = f"architecture.{field_name} must be a mapping."
249
+ raise ArchitectureError(msg)
250
+
251
+ result: dict[str, tuple[str, ...]] = {}
252
+ for key, item in value.items():
253
+ if not isinstance(key, str) or not key.strip():
254
+ msg = (
255
+ f"architecture.{field_name} keys must be non-empty strings."
256
+ )
257
+ raise ArchitectureError(msg)
258
+ result[key.strip()] = self._scalar_values(
259
+ item,
260
+ f"{field_name}.{key}",
261
+ )
262
+ return result
263
+
264
+ def _scalar_values(self, value: Any, field_name: str) -> tuple[str, ...]:
265
+ if isinstance(value, list):
266
+ values = value
267
+ else:
268
+ values = [value]
269
+ if not values:
270
+ msg = f"architecture.{field_name} must not be empty."
271
+ raise ArchitectureError(msg)
272
+
273
+ result: list[str] = []
274
+ for item in values:
275
+ if isinstance(item, bool) or not isinstance(
276
+ item,
277
+ (str, int, float),
278
+ ):
279
+ msg = (
280
+ f"architecture.{field_name} values must be strings or "
281
+ "numbers."
282
+ )
283
+ raise ArchitectureError(msg)
284
+ text = str(item).strip()
285
+ if not text:
286
+ msg = (
287
+ f"architecture.{field_name} values must not be empty."
288
+ )
289
+ raise ArchitectureError(msg)
290
+ result.append(text)
291
+ return tuple(result)
292
+
293
+ def _modules(self, value: Any) -> tuple[ArchitectureModule, ...]:
294
+ if value is None:
295
+ msg = "architecture.modules must declare at least one module."
296
+ raise ArchitectureError(msg)
297
+ if not isinstance(value, list) or not value:
298
+ msg = "architecture.modules must be a non-empty list."
299
+ raise ArchitectureError(msg)
300
+
301
+ modules: list[ArchitectureModule] = []
302
+ seen: set[str] = set()
303
+ for index, item in enumerate(value, start=1):
304
+ if not isinstance(item, dict):
305
+ msg = f"architecture.modules[{index}] must be a mapping."
306
+ raise ArchitectureError(msg)
307
+ name = self._required_text(
308
+ item.get("name"),
309
+ f"modules[{index}].name",
310
+ )
311
+ if name in seen:
312
+ msg = f"architecture.modules contains duplicate name: {name}."
313
+ raise ArchitectureError(msg)
314
+ seen.add(name)
315
+ responsibility = self._required_text(
316
+ item.get("responsibility"),
317
+ f"modules[{index}].responsibility",
318
+ )
319
+ depends_on = self._text_list(
320
+ item.get("depends_on"),
321
+ f"modules[{index}].depends_on",
322
+ )
323
+ modules.append(
324
+ ArchitectureModule(
325
+ name=name,
326
+ responsibility=responsibility,
327
+ depends_on=depends_on,
328
+ )
329
+ )
330
+ return tuple(modules)
331
+
332
+ def _layers(self, value: Any) -> tuple[ArchitectureLayer, ...]:
333
+ if value is None:
334
+ return ()
335
+ if not isinstance(value, list):
336
+ msg = "architecture.layers must be a list."
337
+ raise ArchitectureError(msg)
338
+
339
+ layers: list[ArchitectureLayer] = []
340
+ seen: set[str] = set()
341
+ for index, item in enumerate(value, start=1):
342
+ if not isinstance(item, dict):
343
+ msg = f"architecture.layers[{index}] must be a mapping."
344
+ raise ArchitectureError(msg)
345
+ name = self._required_text(
346
+ item.get("name"),
347
+ f"layers[{index}].name",
348
+ )
349
+ if name in seen:
350
+ msg = f"architecture.layers contains duplicate name: {name}."
351
+ raise ArchitectureError(msg)
352
+ seen.add(name)
353
+ may_depend_on = self._text_list(
354
+ item.get("may_depend_on"),
355
+ f"layers[{index}].may_depend_on",
356
+ )
357
+ layers.append(
358
+ ArchitectureLayer(
359
+ name=name,
360
+ may_depend_on=may_depend_on,
361
+ )
362
+ )
363
+ return tuple(layers)
364
+
365
+ def _check_references(
366
+ self,
367
+ modules: tuple[ArchitectureModule, ...],
368
+ layers: tuple[ArchitectureLayer, ...],
369
+ ) -> None:
370
+ module_names = {module.name for module in modules}
371
+ for module in modules:
372
+ unknown = sorted(set(module.depends_on) - module_names)
373
+ if unknown:
374
+ msg = (
375
+ f"architecture module '{module.name}' depends on unknown "
376
+ "module(s): " + ", ".join(unknown) + "."
377
+ )
378
+ raise ArchitectureError(msg)
379
+
380
+ layer_names = {layer.name for layer in layers}
381
+ for layer in layers:
382
+ unknown = sorted(set(layer.may_depend_on) - layer_names)
383
+ if unknown:
384
+ msg = (
385
+ f"architecture layer '{layer.name}' depends on unknown "
386
+ "layer(s): " + ", ".join(unknown) + "."
387
+ )
388
+ raise ArchitectureError(msg)
389
+
390
+ def _cycles(
391
+ self,
392
+ modules: tuple[ArchitectureModule, ...],
393
+ layers: tuple[ArchitectureLayer, ...],
394
+ ) -> tuple[str, ...]:
395
+ warnings: list[str] = []
396
+ module_cycles = _find_cycles(
397
+ {module.name: module.depends_on for module in modules}
398
+ )
399
+ for cycle in module_cycles:
400
+ warnings.append(
401
+ "architecture modules contain a dependency cycle: "
402
+ + " -> ".join(cycle)
403
+ + "."
404
+ )
405
+
406
+ layer_cycles = _find_cycles(
407
+ {layer.name: layer.may_depend_on for layer in layers}
408
+ )
409
+ for cycle in layer_cycles:
410
+ warnings.append(
411
+ "architecture layers contain a dependency cycle: "
412
+ + " -> ".join(cycle)
413
+ + "."
414
+ )
415
+ return tuple(warnings)
416
+
417
+
418
+ def create_architecture_template(root: Path) -> Path | None:
419
+ """Create the Greenfield manifest template without overwriting files."""
420
+ project_root = root.resolve()
421
+ path = project_root / ARCHITECTURE_RELATIVE_PATH
422
+ if path.is_symlink():
423
+ msg = (
424
+ "Architecture manifest must not be a symbolic link: "
425
+ f"{ARCHITECTURE_RELATIVE_PATH.as_posix()}"
426
+ )
427
+ raise ArchitectureError(msg)
428
+ if path.exists():
429
+ return None
430
+
431
+ try:
432
+ ArtifactStore(project_root).write(path, ARCHITECTURE_TEMPLATE)
433
+ except ArtifactError as error:
434
+ raise ArchitectureError(str(error)) from error
435
+ return path
436
+
437
+
438
+ def _find_cycles(graph: dict[str, tuple[str, ...]]) -> list[tuple[str, ...]]:
439
+ """Return one representative path per strongly connected cycle."""
440
+ visited: set[str] = set()
441
+ active: list[str] = []
442
+ active_set: set[str] = set()
443
+ cycles: list[tuple[str, ...]] = []
444
+ reported: set[frozenset[str]] = set()
445
+
446
+ def visit(node: str) -> None:
447
+ if node in active_set:
448
+ start = active.index(node)
449
+ cycle = tuple(active[start:] + [node])
450
+ key = frozenset(cycle[:-1])
451
+ if key and key not in reported:
452
+ reported.add(key)
453
+ cycles.append(cycle)
454
+ return
455
+ if node in visited:
456
+ return
457
+
458
+ visited.add(node)
459
+ active.append(node)
460
+ active_set.add(node)
461
+ for dependency in graph.get(node, ()):
462
+ if dependency in graph:
463
+ visit(dependency)
464
+ active.pop()
465
+ active_set.remove(node)
466
+
467
+ for node in graph:
468
+ visit(node)
469
+ return cycles
@@ -0,0 +1,29 @@
1
+ """Artifact storage and lifecycle metadata."""
2
+
3
+ from devflow.core.artifacts.errors import (
4
+ ArtifactError,
5
+ ArtifactNotFoundError,
6
+ ArtifactOutsideProjectError,
7
+ ArtifactWriteError,
8
+ )
9
+ from devflow.core.artifacts.models import (
10
+ ARTIFACT_SOURCES,
11
+ ARTIFACT_STATUSES,
12
+ ArtifactRecord,
13
+ ArtifactSource,
14
+ ArtifactStatus,
15
+ )
16
+ from devflow.core.artifacts.store import ArtifactStore
17
+
18
+ __all__ = [
19
+ "ARTIFACT_SOURCES",
20
+ "ARTIFACT_STATUSES",
21
+ "ArtifactError",
22
+ "ArtifactNotFoundError",
23
+ "ArtifactOutsideProjectError",
24
+ "ArtifactRecord",
25
+ "ArtifactSource",
26
+ "ArtifactStatus",
27
+ "ArtifactStore",
28
+ "ArtifactWriteError",
29
+ ]
@@ -0,0 +1,19 @@
1
+ """Artifact-related errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ArtifactError(Exception):
7
+ """Raised when an artifact cannot be read, written or verified."""
8
+
9
+
10
+ class ArtifactNotFoundError(ArtifactError):
11
+ """Raised when an expected artifact does not exist."""
12
+
13
+
14
+ class ArtifactOutsideProjectError(ArtifactError):
15
+ """Raised when an artifact path escapes the project root."""
16
+
17
+
18
+ class ArtifactWriteError(ArtifactError):
19
+ """Raised when an artifact cannot be written atomically."""
@@ -0,0 +1,82 @@
1
+ """Artifact data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class ArtifactStatus(str, Enum):
11
+ """Lifecycle state of a generated artifact."""
12
+
13
+ GENERATED = "generated"
14
+ VALIDATED = "validated"
15
+ APPROVED = "approved"
16
+ REJECTED = "rejected"
17
+ STALE = "stale"
18
+
19
+
20
+ class ArtifactSource(str, Enum):
21
+ """Origin of an artifact."""
22
+
23
+ AGENT = "agent"
24
+ DETERMINISTIC = "deterministic"
25
+ EXISTING = "existing"
26
+ HUMAN = "human"
27
+ SYSTEM = "system"
28
+
29
+
30
+ ARTIFACT_STATUSES = tuple(status.value for status in ArtifactStatus)
31
+ ARTIFACT_SOURCES = tuple(source.value for source in ArtifactSource)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ArtifactRecord:
36
+ """Metadata for one stored artifact."""
37
+
38
+ kind: str
39
+ path: str
40
+ digest: str
41
+ status: str
42
+ source: str
43
+ version: int = 1
44
+ updated_at: str = ""
45
+ validated_at: str = ""
46
+ approved_at: str = ""
47
+ stale_at: str = ""
48
+ metadata: dict[str, Any] = field(default_factory=dict)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "kind": self.kind,
53
+ "path": self.path,
54
+ "digest": self.digest,
55
+ "status": self.status,
56
+ "source": self.source,
57
+ "version": self.version,
58
+ "updated_at": self.updated_at,
59
+ "validated_at": self.validated_at,
60
+ "approved_at": self.approved_at,
61
+ "stale_at": self.stale_at,
62
+ "metadata": dict(self.metadata),
63
+ }
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict[str, Any]) -> ArtifactRecord:
67
+ metadata = data.get("metadata") or {}
68
+ if not isinstance(metadata, dict):
69
+ metadata = {}
70
+ return cls(
71
+ kind=str(data["kind"]),
72
+ path=str(data["path"]),
73
+ digest=str(data["digest"]),
74
+ status=str(data["status"]),
75
+ source=str(data["source"]),
76
+ version=int(data.get("version", 1)),
77
+ updated_at=str(data.get("updated_at", "")),
78
+ validated_at=str(data.get("validated_at", "")),
79
+ approved_at=str(data.get("approved_at", "")),
80
+ stale_at=str(data.get("stale_at", "")),
81
+ metadata=metadata,
82
+ )