cfa-kernel 0.1.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 (98) hide show
  1. cfa/__init__.py +39 -0
  2. cfa/_lazy.py +39 -0
  3. cfa/adapters/__init__.py +104 -0
  4. cfa/adapters/autogen.py +19 -0
  5. cfa/adapters/crewai.py +19 -0
  6. cfa/adapters/dspy.py +19 -0
  7. cfa/adapters/langgraph.py +19 -0
  8. cfa/adapters/openai_agents.py +19 -0
  9. cfa/audit/__init__.py +15 -0
  10. cfa/audit/context.py +205 -0
  11. cfa/audit/hashing.py +41 -0
  12. cfa/audit/trail.py +194 -0
  13. cfa/backends/__init__.py +132 -0
  14. cfa/backends/dbt.py +338 -0
  15. cfa/backends/pyspark.py +240 -0
  16. cfa/backends/sql.py +270 -0
  17. cfa/behavior/__init__.py +49 -0
  18. cfa/behavior/llm.py +244 -0
  19. cfa/behavior/spec.py +235 -0
  20. cfa/behavior/systematizer.py +222 -0
  21. cfa/cli/__init__.py +296 -0
  22. cfa/cli/__main__.py +6 -0
  23. cfa/cli/_helpers.py +109 -0
  24. cfa/cli/core/__init__.py +0 -0
  25. cfa/cli/core/evaluate.py +72 -0
  26. cfa/cli/core/validate.py +29 -0
  27. cfa/cli/formatters.py +280 -0
  28. cfa/cli/governance/__init__.py +0 -0
  29. cfa/cli/governance/audit.py +65 -0
  30. cfa/cli/governance/catalog.py +28 -0
  31. cfa/cli/governance/policy.py +119 -0
  32. cfa/cli/governance/rules.py +42 -0
  33. cfa/cli/governance/signature.py +31 -0
  34. cfa/cli/infrastructure/__init__.py +0 -0
  35. cfa/cli/infrastructure/backend_list.py +24 -0
  36. cfa/cli/infrastructure/storage.py +87 -0
  37. cfa/cli/project/__init__.py +0 -0
  38. cfa/cli/project/init.py +73 -0
  39. cfa/cli/project/lifecycle.py +92 -0
  40. cfa/cli/project/status.py +75 -0
  41. cfa/cli/project/taxonomy.py +38 -0
  42. cfa/cli/reporting/__init__.py +0 -0
  43. cfa/cli/reporting/report.py +109 -0
  44. cfa/cli/reporting/serve.py +43 -0
  45. cfa/config.py +103 -0
  46. cfa/core/__init__.py +19 -0
  47. cfa/core/codegen.py +65 -0
  48. cfa/core/conditions.py +129 -0
  49. cfa/core/kernel.py +224 -0
  50. cfa/core/phases/__init__.py +0 -0
  51. cfa/core/phases/runner.py +477 -0
  52. cfa/core/planner.py +290 -0
  53. cfa/execution/__init__.py +12 -0
  54. cfa/execution/partial.py +339 -0
  55. cfa/execution/state_projection.py +216 -0
  56. cfa/governance/__init__.py +76 -0
  57. cfa/lifecycle/__init__.py +51 -0
  58. cfa/mcp/__init__.py +347 -0
  59. cfa/mcp/__main__.py +4 -0
  60. cfa/normalizer/__init__.py +15 -0
  61. cfa/normalizer/base.py +441 -0
  62. cfa/normalizer/llm.py +426 -0
  63. cfa/observability/__init__.py +14 -0
  64. cfa/observability/indices.py +177 -0
  65. cfa/observability/metrics.py +91 -0
  66. cfa/observability/notify.py +79 -0
  67. cfa/observability/otel.py +81 -0
  68. cfa/observability/promotion.py +367 -0
  69. cfa/policy/__init__.py +12 -0
  70. cfa/policy/bundle.py +317 -0
  71. cfa/policy/catalog.py +117 -0
  72. cfa/policy/engine.py +306 -0
  73. cfa/reporting/__init__.py +42 -0
  74. cfa/reporting/charts.py +223 -0
  75. cfa/reporting/engine.py +456 -0
  76. cfa/resolution/__init__.py +62 -0
  77. cfa/runtime/__init__.py +13 -0
  78. cfa/runtime/gate.py +287 -0
  79. cfa/sandbox/__init__.py +189 -0
  80. cfa/sandbox/executor.py +92 -0
  81. cfa/sandbox/mock.py +89 -0
  82. cfa/sandbox/panic.py +52 -0
  83. cfa/storage/__init__.py +591 -0
  84. cfa/testing/__init__.py +60 -0
  85. cfa/testing/asserts.py +77 -0
  86. cfa/testing/evaluate.py +168 -0
  87. cfa/testing/fixtures.py +89 -0
  88. cfa/testing/markers.py +36 -0
  89. cfa/types.py +489 -0
  90. cfa/validation/__init__.py +14 -0
  91. cfa/validation/runtime.py +285 -0
  92. cfa/validation/signature.py +146 -0
  93. cfa/validation/static.py +252 -0
  94. cfa_kernel-0.1.0.dist-info/METADATA +32 -0
  95. cfa_kernel-0.1.0.dist-info/RECORD +98 -0
  96. cfa_kernel-0.1.0.dist-info/WHEEL +4 -0
  97. cfa_kernel-0.1.0.dist-info/entry_points.txt +3 -0
  98. cfa_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
cfa/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ CFA — Contextual Flux Architecture
3
+ ===================================
4
+ Governed execution for AI agents and data systems.
5
+ """
6
+
7
+ from cfa._lazy import LazyLoader
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __getattr__ = LazyLoader({
12
+ "KernelOrchestrator": ("cfa.core.kernel", "KernelOrchestrator"),
13
+ "KernelConfig": ("cfa.core.kernel", "KernelConfig"),
14
+ "PipelinePhase": ("cfa.core.kernel", "PipelinePhase"),
15
+ "DecisionState": ("cfa.types", "DecisionState"),
16
+ "StateSignature": ("cfa.types", "StateSignature"),
17
+ "KernelResult": ("cfa.types", "KernelResult"),
18
+ "TargetLayer": ("cfa.types", "TargetLayer"),
19
+ "Fault": ("cfa.types", "Fault"),
20
+ "FaultFamily": ("cfa.types", "FaultFamily"),
21
+ "FaultSeverity": ("cfa.types", "FaultSeverity"),
22
+ "PolicyAction": ("cfa.types", "PolicyAction"),
23
+ "ContextRegistry": ("cfa.audit.context", "ContextRegistry"),
24
+ "JsonFileContextStorage": ("cfa.audit.context", "JsonFileContextStorage"),
25
+ "AuditTrail": ("cfa.audit.trail", "AuditTrail"),
26
+ "JsonLinesAuditStorage": ("cfa.audit.trail", "JsonLinesAuditStorage"),
27
+ "BackendAdapter": ("cfa.backends", "BackendAdapter"),
28
+ "BackendCapabilities": ("cfa.backends", "BackendCapabilities"),
29
+ "BackendRegistry": ("cfa.backends", "BackendRegistry"),
30
+ "PySparkBackend": ("cfa.backends.pyspark", "PySparkBackend"),
31
+ "evaluate": ("cfa.testing.evaluate", "evaluate"),
32
+ "EvaluationResult": ("cfa.testing.evaluate", "EvaluationResult"),
33
+ "BehaviorSpec": ("cfa.behavior.spec", "BehaviorSpec"),
34
+ "BehaviorTaxonomy": ("cfa.behavior.spec", "BehaviorTaxonomy"),
35
+ "Systematizer": ("cfa.behavior.systematizer", "Systematizer"),
36
+ "RuntimeGate": ("cfa.runtime.gate", "RuntimeGate"),
37
+ "GateConfig": ("cfa.runtime.gate", "GateConfig"),
38
+ "GovernanceViolation": ("cfa.runtime.gate", "GovernanceViolation"),
39
+ })
cfa/_lazy.py ADDED
@@ -0,0 +1,39 @@
1
+ """Reusable lazy import helper for package __init__.py files.
2
+
3
+ Usage::
4
+
5
+ from cfa._lazy import LazyLoader
6
+
7
+ __getattr__ = LazyLoader({
8
+ "KernelOrchestrator": ("cfa.core.kernel", "KernelOrchestrator"),
9
+ "KernelConfig": ("cfa.core.kernel", "KernelConfig"),
10
+ })
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ from typing import Any
17
+
18
+
19
+ class LazyLoader:
20
+ """Callable that resolves symbols lazily from a mapping.
21
+
22
+ Replace ``__getattr__`` in any package ``__init__.py``::
23
+
24
+ from cfa._lazy import LazyLoader
25
+ __getattr__ = LazyLoader({"Symbol": ("package.module", "Symbol")})
26
+ """
27
+
28
+ __slots__ = ("_map",)
29
+
30
+ def __init__(self, mapping: dict[str, tuple[str, str]]) -> None:
31
+ self._map = mapping
32
+
33
+ def __call__(self, name: str) -> Any:
34
+ entry = self._map.get(name)
35
+ if entry is None:
36
+ raise AttributeError(f"module has no attribute {name!r}")
37
+ module_path, attr = entry
38
+ module = importlib.import_module(module_path)
39
+ return getattr(module, attr)
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable # noqa: F401
4
+ from functools import wraps
5
+ from typing import Any
6
+
7
+ from cfa.core.kernel import KernelConfig, KernelOrchestrator
8
+
9
+
10
+ class CFAGuard:
11
+ """Base governance guard for any callable.
12
+
13
+ Wraps a function with CFA policy validation before execution.
14
+ The function's first argument or a provided intent string is
15
+ evaluated against the policy engine.
16
+
17
+ Usage:
18
+ guard = CFAGuard(policy_bundle="prod-v1", catalog=my_catalog)
19
+
20
+ @guard("aggregate sales data")
21
+ def my_pipeline(): ...
22
+
23
+ @guard # uses function name/docstring as intent
24
+ def another_pipeline(): ...
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ policy_bundle: str = "v1.0",
30
+ catalog: dict[str, Any] | None = None,
31
+ backend: str = "pyspark",
32
+ mode: str = "block", # block | warn | audit
33
+ **kernel_kwargs: Any,
34
+ ) -> None:
35
+ self._config = KernelConfig(
36
+ policy_bundle_version=policy_bundle,
37
+ backend=backend,
38
+ warnings_are_blocking=(mode == "block"),
39
+ )
40
+ self._catalog = catalog
41
+ self._mode = mode
42
+ self._kernel_kwargs = kernel_kwargs
43
+
44
+ def __call__(self, fn_or_intent):
45
+ if isinstance(fn_or_intent, str):
46
+ intent = fn_or_intent
47
+ def decorator(fn):
48
+ @wraps(fn)
49
+ def wrapper(*args, **kwargs):
50
+ self._check(intent)
51
+ return fn(*args, **kwargs)
52
+ return wrapper
53
+ return decorator
54
+
55
+ fn = fn_or_intent
56
+ intent = fn.__doc__ or fn.__name__ if hasattr(fn, "__doc__") else str(fn)
57
+
58
+ @wraps(fn)
59
+ def wrapper(*args, **kwargs):
60
+ self._check(intent)
61
+ return fn(*args, **kwargs)
62
+ return wrapper
63
+
64
+ def guard(self, intent: str):
65
+ """Explicit guard with intent string."""
66
+ def decorator(fn):
67
+ @wraps(fn)
68
+ def wrapper(*args, **kwargs):
69
+ self._check(intent)
70
+ return fn(*args, **kwargs)
71
+ return wrapper
72
+ return decorator
73
+
74
+ def _check(self, intent: str) -> None:
75
+ kernel = KernelOrchestrator(
76
+ catalog=self._catalog, config=self._config, **self._kernel_kwargs
77
+ )
78
+ result = kernel.process(intent)
79
+ if not result.is_executable and self._mode == "block":
80
+ raise PermissionError(
81
+ f"CFA blocked intent '{intent[:80]}': {result.blocked_reason}"
82
+ )
83
+
84
+
85
+ def cfa_guard(
86
+ intent: str | None = None,
87
+ *,
88
+ policy_bundle: str = "v1.0",
89
+ catalog: dict[str, Any] | None = None,
90
+ mode: str = "block",
91
+ **kwargs: Any,
92
+ ):
93
+ """Universal CFA governance guard for any callable or agent tool.
94
+
95
+ Args:
96
+ intent: Intent string to validate. If None, uses function docstring/name.
97
+ policy_bundle: Policy bundle version or path to YAML file.
98
+ catalog: Data catalog with dataset metadata.
99
+ mode: 'block' (raise), 'warn' (log), or 'audit' (silent record).
100
+ """
101
+ guard = CFAGuard(policy_bundle=policy_bundle, catalog=catalog, mode=mode, **kwargs)
102
+ if intent:
103
+ return guard.guard(intent)
104
+ return guard
@@ -0,0 +1,19 @@
1
+ """AutoGen adapter — CFA governance for agent functions.
2
+
3
+ Usage::
4
+
5
+ from cfa.adapters.autogen import cfa_agent_guard
6
+
7
+ @cfa_agent_guard("analyze customer data without raw PII",
8
+ policy_bundle="compliance-strict-v1")
9
+ def analyze(state):
10
+ ...
11
+
12
+ The decorator is equivalent to ``cfa.adapters.cfa_guard``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ..adapters import cfa_guard as _cfa_guard
18
+
19
+ cfa_agent_guard = _cfa_guard
cfa/adapters/crewai.py ADDED
@@ -0,0 +1,19 @@
1
+ """CrewAI adapter — CFA governance for crew tasks.
2
+
3
+ Usage::
4
+
5
+ from cfa.adapters.crewai import cfa_crew_guard
6
+
7
+ @cfa_crew_guard("extract financial data from Silver layer",
8
+ policy_bundle="finops-strict-v1")
9
+ def extract_task():
10
+ ...
11
+
12
+ The decorator is equivalent to ``cfa.adapters.cfa_guard``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ..adapters import cfa_guard as _cfa_guard
18
+
19
+ cfa_crew_guard = _cfa_guard
cfa/adapters/dspy.py ADDED
@@ -0,0 +1,19 @@
1
+ """DSPy adapter — CFA governance for DSPy modules.
2
+
3
+ Usage::
4
+
5
+ from cfa.adapters.dspy import cfa_module_guard
6
+
7
+ @cfa_module_guard("classify transactions with PII protected",
8
+ policy_bundle="prod-v1")
9
+ class TransactionClassifier(dspy.Module):
10
+ ...
11
+
12
+ The decorator is equivalent to ``cfa.adapters.cfa_guard``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ..adapters import cfa_guard as _cfa_guard
18
+
19
+ cfa_module_guard = _cfa_guard
@@ -0,0 +1,19 @@
1
+ """LangGraph adapter — CFA governance for LangGraph agent nodes.
2
+
3
+ CFA validates the node's declared intent before every invocation. Usage::
4
+
5
+ from cfa.adapters.langgraph import cfa_guard
6
+
7
+ @cfa_guard("aggregate sales with PII protected", policy_bundle="prod-v1")
8
+ def my_node(state: dict) -> dict:
9
+ ...
10
+
11
+ The decorator works identically to ``cfa.adapters.cfa_guard``. It raises
12
+ ``PermissionError`` when the policy blocks the intent (``mode="block"``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ..adapters import cfa_guard as _cfa_guard
18
+
19
+ cfa_guard = _cfa_guard
@@ -0,0 +1,19 @@
1
+ """OpenAI Agents SDK adapter — CFA governance for tool functions.
2
+
3
+ Usage::
4
+
5
+ from cfa.adapters.openai_agents import cfa_tool_guard
6
+
7
+ @cfa_tool_guard("query customer data with PII masked", policy_bundle="prod-v1")
8
+ def query_customers(region: str) -> str:
9
+ ...
10
+
11
+ The decorator is equivalent to ``cfa.adapters.cfa_guard``. Before every tool
12
+ call, CFA validates the declared intent against the active policy bundle.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ..adapters import cfa_guard as _cfa_guard
18
+
19
+ cfa_tool_guard = _cfa_guard
cfa/audit/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """CFA Audit — trail, context, and hashing."""
2
+ from cfa._lazy import LazyLoader
3
+
4
+ __getattr__ = LazyLoader({
5
+ "AuditTrail": ("cfa.audit.trail", "AuditTrail"),
6
+ "AuditEvent": ("cfa.audit.trail", "AuditEvent"),
7
+ "AuditStorageBackend": ("cfa.audit.trail", "AuditStorageBackend"),
8
+ "InMemoryAuditStorage": ("cfa.audit.trail", "InMemoryAuditStorage"),
9
+ "JsonLinesAuditStorage": ("cfa.audit.trail", "JsonLinesAuditStorage"),
10
+ "ContextRegistry": ("cfa.audit.context", "ContextRegistry"),
11
+ "InMemoryContextStorage": ("cfa.audit.context", "InMemoryContextStorage"),
12
+ "JsonFileContextStorage": ("cfa.audit.context", "JsonFileContextStorage"),
13
+ "hash_governance_artifact": ("cfa.audit.hashing", "hash_governance_artifact"),
14
+ "hash_file_content": ("cfa.audit.hashing", "hash_file_content"),
15
+ })
cfa/audit/context.py ADDED
@@ -0,0 +1,205 @@
1
+ """
2
+ CFA Context Registry
3
+ ====================
4
+ Live model of the environment state.
5
+ Not an execution log — represents "what state is the data in right now".
6
+
7
+ Phase 1: in-memory implementation.
8
+ Phase 4: persistent backend (JSON file) + snapshot versioning.
9
+
10
+ The Context Registry is consulted before every intent (Invariant I3)
11
+ and updated after every execution (Invariant I4).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import uuid
18
+ from abc import ABC, abstractmethod
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from cfa.types import _utcnow
24
+
25
+ # ── Storage Backend ─────────────────────────────────────────────────────────
26
+
27
+
28
+ class ContextStorageBackend(ABC):
29
+ """Extension point: pluggable persistence for the Context Registry."""
30
+
31
+ @abstractmethod
32
+ def load(self) -> dict[str, Any]:
33
+ """Load full state from storage. Returns empty dict if no state exists."""
34
+ ...
35
+
36
+ @abstractmethod
37
+ def save(self, state: dict[str, Any]) -> None:
38
+ """Persist full state to storage."""
39
+ ...
40
+
41
+ @abstractmethod
42
+ def save_snapshot(self, version_id: str, state: dict[str, Any]) -> None:
43
+ """Save a versioned snapshot (Invariant I8 — reproducibility)."""
44
+ ...
45
+
46
+ @abstractmethod
47
+ def load_snapshot(self, version_id: str) -> dict[str, Any] | None:
48
+ """Load a specific snapshot by version_id."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ def list_snapshots(self) -> list[str]:
53
+ """List all available snapshot version_ids."""
54
+ ...
55
+
56
+
57
+ class InMemoryContextStorage(ContextStorageBackend):
58
+ """In-memory storage for testing."""
59
+
60
+ def __init__(self) -> None:
61
+ self._state: dict[str, Any] = {}
62
+ self._snapshots: dict[str, dict[str, Any]] = {}
63
+
64
+ def load(self) -> dict[str, Any]:
65
+ return dict(self._state)
66
+
67
+ def save(self, state: dict[str, Any]) -> None:
68
+ self._state = dict(state)
69
+
70
+ def save_snapshot(self, version_id: str, state: dict[str, Any]) -> None:
71
+ self._snapshots[version_id] = dict(state)
72
+
73
+ def load_snapshot(self, version_id: str) -> dict[str, Any] | None:
74
+ return self._snapshots.get(version_id)
75
+
76
+ def list_snapshots(self) -> list[str]:
77
+ return list(self._snapshots.keys())
78
+
79
+
80
+ class JsonFileContextStorage(ContextStorageBackend):
81
+ """
82
+ JSON file-based persistent storage.
83
+ - Current state: {base_path}/context_state.json
84
+ - Snapshots: {base_path}/snapshots/{version_id}.json
85
+ """
86
+
87
+ def __init__(self, base_path: str | Path) -> None:
88
+ self.base_path = Path(base_path)
89
+ self.base_path.mkdir(parents=True, exist_ok=True)
90
+ self._snapshots_dir = self.base_path / "snapshots"
91
+ self._snapshots_dir.mkdir(exist_ok=True)
92
+
93
+ def _state_file(self) -> Path:
94
+ return self.base_path / "context_state.json"
95
+
96
+ def load(self) -> dict[str, Any]:
97
+ path = self._state_file()
98
+ if not path.exists():
99
+ return {}
100
+ return json.loads(path.read_text(encoding="utf-8"))
101
+
102
+ def save(self, state: dict[str, Any]) -> None:
103
+ self._state_file().write_text(
104
+ json.dumps(state, indent=2, default=str), encoding="utf-8"
105
+ )
106
+
107
+ def save_snapshot(self, version_id: str, state: dict[str, Any]) -> None:
108
+ path = self._snapshots_dir / f"{version_id}.json"
109
+ path.write_text(json.dumps(state, indent=2, default=str), encoding="utf-8")
110
+
111
+ def load_snapshot(self, version_id: str) -> dict[str, Any] | None:
112
+ path = self._snapshots_dir / f"{version_id}.json"
113
+ if not path.exists():
114
+ return None
115
+ return json.loads(path.read_text(encoding="utf-8"))
116
+
117
+ def list_snapshots(self) -> list[str]:
118
+ return sorted(p.stem for p in self._snapshots_dir.glob("*.json"))
119
+
120
+
121
+ # ── Context Registry ────────────────────────────────────────────────────────
122
+
123
+
124
+ @dataclass
125
+ class ContextRegistry:
126
+ """
127
+ Context Registry for the CFA Kernel.
128
+
129
+ Consulted by the Intent Normalizer before every intent (Invariant I3).
130
+ Updated by the State Projection Protocol after every execution (Invariant I4).
131
+
132
+ Supports pluggable persistence backends and snapshot versioning.
133
+ """
134
+
135
+ _datasets: dict[str, dict[str, Any]] = field(default_factory=dict)
136
+ _execution_history: list[dict[str, Any]] = field(default_factory=list)
137
+ _version_id: str = "v_initial"
138
+ _storage: ContextStorageBackend = field(default_factory=InMemoryContextStorage)
139
+
140
+ def __post_init__(self) -> None:
141
+ # Try to restore from persistent storage
142
+ saved = self._storage.load()
143
+ if saved:
144
+ self._datasets = saved.get("datasets", {})
145
+ self._execution_history = saved.get("execution_history", [])
146
+ self._version_id = saved.get("version_id", self._version_id)
147
+
148
+ @property
149
+ def version_id(self) -> str:
150
+ return self._version_id
151
+
152
+ def get_environment_state(self) -> dict[str, Any]:
153
+ return {
154
+ "datasets": dict(self._datasets),
155
+ "execution_history": list(self._execution_history),
156
+ "version_id": self._version_id,
157
+ }
158
+
159
+ def get_dataset_state(self, name: str) -> dict[str, Any] | None:
160
+ return self._datasets.get(name)
161
+
162
+ def set_dataset_state(self, name: str, state: dict[str, Any]) -> None:
163
+ self._datasets[name] = state
164
+ self._bump_version()
165
+ self._persist()
166
+
167
+ def record_execution(
168
+ self, intent_id: str, outcome: str, signature_hash: str
169
+ ) -> None:
170
+ self._execution_history.append(
171
+ {
172
+ "intent_id": intent_id,
173
+ "outcome": outcome,
174
+ "signature_hash": signature_hash,
175
+ "timestamp": _utcnow().isoformat(),
176
+ "version_id": self._version_id,
177
+ }
178
+ )
179
+ self._persist()
180
+
181
+ def snapshot(self) -> str:
182
+ """Create a versioned snapshot of the current state (Invariant I8)."""
183
+ state = self.get_environment_state()
184
+ self._storage.save_snapshot(self._version_id, state)
185
+ return self._version_id
186
+
187
+ def restore_snapshot(self, version_id: str) -> bool:
188
+ """Restore state from a specific snapshot. Returns False if not found."""
189
+ saved = self._storage.load_snapshot(version_id)
190
+ if saved is None:
191
+ return False
192
+ self._datasets = saved.get("datasets", {})
193
+ self._execution_history = saved.get("execution_history", [])
194
+ self._version_id = saved.get("version_id", version_id)
195
+ self._persist()
196
+ return True
197
+
198
+ def list_snapshots(self) -> list[str]:
199
+ return self._storage.list_snapshots()
200
+
201
+ def _bump_version(self) -> None:
202
+ self._version_id = f"v_{uuid.uuid4().hex[:8]}"
203
+
204
+ def _persist(self) -> None:
205
+ self._storage.save(self.get_environment_state())
cfa/audit/hashing.py ADDED
@@ -0,0 +1,41 @@
1
+ """Deterministic content-addressable hashing for CFA governance artifacts.
2
+
3
+ Every artifact that participates in a governed decision (catalog, policy bundle,
4
+ signature) MUST be hashable so the audit trail can cryptographically bind a
5
+ decision to its exact inputs.
6
+
7
+ Hashes are computed on canonical JSON serializations of the artifact data,
8
+ ensuring the same content always produces the same hash regardless of formatting
9
+ or field order in the original YAML/JSON file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ from typing import Any
17
+
18
+
19
+ def hash_governance_artifact(data: dict[str, Any] | None) -> str:
20
+ """Return a deterministic SHA-256 hash for a governance artifact.
21
+
22
+ The artifact is serialized to JSON with sorted keys so that the same logical
23
+ content always produces the same hash.
24
+
25
+ Returns an empty string when data is None.
26
+ """
27
+ if data is None:
28
+ return ""
29
+ canonical = json.dumps(data, sort_keys=True, default=str, ensure_ascii=False, separators=(",", ":"))
30
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
31
+
32
+
33
+ def hash_file_content(path: str) -> str:
34
+ """Return the SHA-256 hash of a file's raw content.
35
+
36
+ Unlike ``hash_governance_artifact`` this hashes the bytes on disk, which is
37
+ useful for verifying that a file has not changed even if its logical content
38
+ (after parsing) is identical.
39
+ """
40
+ with open(path, "rb") as f:
41
+ return hashlib.sha256(f.read()).hexdigest()