hapax-agentgov 0.2.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.
agentgov/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """agentgov — Computational constitutional governance for AI agent systems.
2
+
3
+ Pure governance logic with algebraic guarantees:
4
+ - ConsentLabel: join-semilattice (associative, commutative, idempotent)
5
+ - Labeled[T]: functor (identity, composition)
6
+ - Principal: non-amplification (bound <= delegator authority)
7
+ - Governor: consistent with can_flow_to
8
+ - ProvenanceExpr: PosBool(X) semiring
9
+ - VetoChain: deny-wins composition
10
+ - Says: DCC-style principal attribution monad
11
+ """
12
+
13
+ from agentgov.agent_governor import create_agent_governor
14
+ from agentgov.carrier import CarrierFact, CarrierRegistry, DisplacementResult
15
+ from agentgov.consent import ConsentContract, ConsentRegistry, load_contracts
16
+ from agentgov.consent_label import ConsentLabel
17
+ from agentgov.governor import (
18
+ GovernorDenial,
19
+ GovernorPolicy,
20
+ GovernorResult,
21
+ GovernorWrapper,
22
+ consent_input_policy,
23
+ consent_output_policy,
24
+ )
25
+ from agentgov.hooks import (
26
+ HookResult,
27
+ scan_attribution_entities,
28
+ scan_management_boundary,
29
+ scan_pii,
30
+ scan_provenance_references,
31
+ scan_single_user_violations,
32
+ validate_all,
33
+ )
34
+ from agentgov.labeled import Labeled
35
+ from agentgov.primitives import (
36
+ Candidate,
37
+ FallbackChain,
38
+ GatedResult,
39
+ Selected,
40
+ Veto,
41
+ VetoChain,
42
+ VetoResult,
43
+ )
44
+ from agentgov.principal import Principal, PrincipalKind
45
+ from agentgov.provenance import ProvenanceExpr
46
+ from agentgov.revocation import (
47
+ PurgeResult,
48
+ RevocationPropagator,
49
+ RevocationReport,
50
+ check_provenance,
51
+ )
52
+ from agentgov.says import Says
53
+
54
+ __version__ = "0.2.0"
55
+
56
+ __all__ = [
57
+ # Principal model
58
+ "Principal",
59
+ "PrincipalKind",
60
+ # Consent
61
+ "ConsentContract",
62
+ "ConsentRegistry",
63
+ "ConsentLabel",
64
+ "load_contracts",
65
+ # Labeled data
66
+ "Labeled",
67
+ # Provenance
68
+ "ProvenanceExpr",
69
+ # Carrier dynamics
70
+ "CarrierFact",
71
+ "CarrierRegistry",
72
+ "DisplacementResult",
73
+ # Governor
74
+ "GovernorWrapper",
75
+ "GovernorPolicy",
76
+ "GovernorResult",
77
+ "GovernorDenial",
78
+ "consent_input_policy",
79
+ "consent_output_policy",
80
+ "create_agent_governor",
81
+ # Revocation cascade
82
+ "RevocationPropagator",
83
+ "RevocationReport",
84
+ "PurgeResult",
85
+ "check_provenance",
86
+ # Compositional primitives
87
+ "Candidate",
88
+ "FallbackChain",
89
+ "GatedResult",
90
+ "Selected",
91
+ "Veto",
92
+ "VetoChain",
93
+ "VetoResult",
94
+ # Says monad
95
+ "Says",
96
+ # Governance hooks
97
+ "HookResult",
98
+ "scan_pii",
99
+ "scan_single_user_violations",
100
+ "scan_attribution_entities",
101
+ "scan_provenance_references",
102
+ "scan_management_boundary",
103
+ "validate_all",
104
+ ]
@@ -0,0 +1,129 @@
1
+ """Agent governor factory: builds GovernorWrapper from axiom bindings.
2
+
3
+ Translates declarative axiom bindings in agent manifests into runtime
4
+ governance policies. Each agent gets a GovernorWrapper configured with
5
+ input/output policies derived from its axiom relationships.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from agentgov.consent_label import ConsentLabel
15
+ from agentgov.governor import (
16
+ GovernorPolicy,
17
+ GovernorWrapper,
18
+ consent_input_policy,
19
+ consent_output_policy,
20
+ )
21
+ from agentgov.labeled import Labeled
22
+
23
+ _log = logging.getLogger(__name__)
24
+
25
+ PolicyBuilder = Callable[[str], tuple[list[GovernorPolicy], list[GovernorPolicy]]]
26
+
27
+
28
+ def interpersonal_transparency_policies(
29
+ role: str,
30
+ ) -> tuple[list[GovernorPolicy], list[GovernorPolicy]]:
31
+ """Build policies for the interpersonal_transparency axiom."""
32
+ input_policies: list[GovernorPolicy] = []
33
+ output_policies: list[GovernorPolicy] = []
34
+
35
+ if role in ("subject", "enforcer"):
36
+ input_policies.append(consent_input_policy(ConsentLabel.bottom()))
37
+ output_policies.append(consent_output_policy(ConsentLabel.bottom()))
38
+
39
+ return input_policies, output_policies
40
+
41
+
42
+ def corporate_boundary_policies(
43
+ role: str,
44
+ ) -> tuple[list[GovernorPolicy], list[GovernorPolicy]]:
45
+ """Build policies for the corporate_boundary axiom."""
46
+ input_policies: list[GovernorPolicy] = []
47
+ output_policies: list[GovernorPolicy] = []
48
+
49
+ if role in ("subject", "enforcer"):
50
+
51
+ def _no_work_data(_agent_id: str, data: Labeled[Any]) -> bool:
52
+ if not (hasattr(data, "metadata") and isinstance(data.metadata, dict)):
53
+ _log.warning("corporate_boundary: denying data with no metadata dict (fail-closed)")
54
+ return False
55
+ category = data.metadata.get("data_category")
56
+ if category is None:
57
+ _log.warning(
58
+ "corporate_boundary: denying data with no data_category key (fail-closed)"
59
+ )
60
+ return False
61
+ return category != "work"
62
+
63
+ output_policies.append(
64
+ GovernorPolicy(
65
+ name="corporate_boundary_output",
66
+ check=_no_work_data,
67
+ axiom_id="corporate_boundary",
68
+ description="Block work data from persisting to home system",
69
+ )
70
+ )
71
+
72
+ return input_policies, output_policies
73
+
74
+
75
+ DEFAULT_AXIOM_BUILDERS: dict[str, PolicyBuilder] = {
76
+ "interpersonal_transparency": interpersonal_transparency_policies,
77
+ "corporate_boundary": corporate_boundary_policies,
78
+ }
79
+
80
+
81
+ def create_agent_governor(
82
+ agent_id: str,
83
+ axiom_bindings: list[dict[str, Any]] | None = None,
84
+ *,
85
+ axiom_builders: dict[str, PolicyBuilder] | None = None,
86
+ binding_loader: Callable[[str], list[Any]] | None = None,
87
+ ) -> GovernorWrapper:
88
+ """Build a GovernorWrapper from agent manifest axiom bindings.
89
+
90
+ Args:
91
+ agent_id: Agent identifier.
92
+ axiom_bindings: List of binding dicts with keys 'axiom_id', 'role'.
93
+ axiom_builders: Custom axiom-to-policy mapping. Defaults to
94
+ built-in interpersonal_transparency and corporate_boundary.
95
+ binding_loader: Optional callable to load bindings from an
96
+ external registry when axiom_bindings is None.
97
+ """
98
+ gov = GovernorWrapper(agent_id)
99
+ builders = axiom_builders or DEFAULT_AXIOM_BUILDERS
100
+
101
+ bindings = axiom_bindings
102
+ if bindings is None and binding_loader is not None:
103
+ bindings = binding_loader(agent_id)
104
+ if bindings is None:
105
+ bindings = []
106
+
107
+ for binding in bindings:
108
+ axiom_id = binding.get("axiom_id", "") if isinstance(binding, dict) else binding.axiom_id
109
+ role = binding.get("role", "subject") if isinstance(binding, dict) else binding.role
110
+
111
+ builder = builders.get(axiom_id)
112
+ if builder is None:
113
+ continue
114
+
115
+ input_policies, output_policies = builder(role)
116
+ for p in input_policies:
117
+ gov.add_input_policy(p)
118
+ for p in output_policies:
119
+ gov.add_output_policy(p)
120
+
121
+ _log.debug(
122
+ "Governor %s: added %d input + %d output policies for %s",
123
+ agent_id,
124
+ len(input_policies),
125
+ len(output_policies),
126
+ axiom_id,
127
+ )
128
+
129
+ return gov
agentgov/carrier.py ADDED
@@ -0,0 +1,138 @@
1
+ """Epistemic carrier dynamics: bounded cross-domain fact carrying.
2
+
3
+ Each agent carries a small set of foreign-domain facts observed
4
+ incidentally through contact topology. Carrier facts are Labeled[Any]
5
+ values — consent labels travel with carried facts via the DLM join.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from agentgov.consent_label import ConsentLabel
15
+ from agentgov.labeled import Labeled
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CarrierFact:
20
+ """A foreign-domain fact carried incidentally by an agent."""
21
+
22
+ labeled: Labeled[Any]
23
+ source_domain: str
24
+ observation_count: int = 1
25
+ first_seen: float = 0.0
26
+ last_seen: float = 0.0
27
+
28
+ def observe(self, timestamp: float) -> CarrierFact:
29
+ """Return a new instance with incremented count and updated last_seen."""
30
+ return CarrierFact(
31
+ labeled=self.labeled,
32
+ source_domain=self.source_domain,
33
+ observation_count=self.observation_count + 1,
34
+ first_seen=self.first_seen,
35
+ last_seen=timestamp,
36
+ )
37
+
38
+ def same_fact(self, other: CarrierFact) -> bool:
39
+ """Check if two carrier facts represent the same observation."""
40
+ return (
41
+ self.labeled.value == other.labeled.value and self.source_domain == other.source_domain
42
+ )
43
+
44
+ @property
45
+ def consent_label(self) -> ConsentLabel:
46
+ return self.labeled.label
47
+
48
+ @property
49
+ def provenance(self) -> frozenset[str]:
50
+ return self.labeled.provenance
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class DisplacementResult:
55
+ """Outcome of offering a carrier fact to a registry."""
56
+
57
+ inserted: bool
58
+ displaced: CarrierFact | None = None
59
+ reason: str = ""
60
+
61
+
62
+ class CarrierRegistry:
63
+ """Mutable registry of carrier facts per principal.
64
+
65
+ Enforces bounded capacity per principal. Displacement follows
66
+ frequency-weighted policy: new facts must be observed significantly
67
+ more frequently than the least-observed existing fact to displace it.
68
+ """
69
+
70
+ __slots__ = ("_slots", "_capacities", "displacement_threshold")
71
+
72
+ def __init__(self, displacement_threshold: float = 2.0) -> None:
73
+ self._slots: dict[str, list[CarrierFact]] = {}
74
+ self._capacities: dict[str, int] = {}
75
+ self.displacement_threshold = displacement_threshold
76
+
77
+ def register(self, principal_id: str, capacity: int) -> None:
78
+ if capacity < 0:
79
+ raise ValueError(f"Carrier capacity must be non-negative, got {capacity}")
80
+ self._slots.setdefault(principal_id, [])
81
+ self._capacities[principal_id] = capacity
82
+
83
+ def facts(self, principal_id: str) -> tuple[CarrierFact, ...]:
84
+ return tuple(self._slots.get(principal_id, []))
85
+
86
+ def offer(self, principal_id: str, fact: CarrierFact) -> DisplacementResult:
87
+ """Offer a carrier fact to a principal's slots."""
88
+ if principal_id not in self._capacities:
89
+ raise ValueError(f"Principal {principal_id} not registered")
90
+
91
+ slots = self._slots[principal_id]
92
+ capacity = self._capacities[principal_id]
93
+
94
+ for i, existing in enumerate(slots):
95
+ if existing.same_fact(fact):
96
+ slots[i] = existing.observe(fact.last_seen)
97
+ return DisplacementResult(inserted=True, reason="updated existing")
98
+
99
+ if len(slots) < capacity:
100
+ slots.append(fact)
101
+ return DisplacementResult(inserted=True, reason="slot available")
102
+
103
+ if not slots:
104
+ return DisplacementResult(inserted=False, reason="zero capacity")
105
+
106
+ least_idx = min(range(len(slots)), key=lambda i: slots[i].observation_count)
107
+ least = slots[least_idx]
108
+
109
+ if fact.observation_count > least.observation_count * self.displacement_threshold:
110
+ displaced = slots[least_idx]
111
+ slots[least_idx] = fact
112
+ return DisplacementResult(inserted=True, displaced=displaced, reason="displaced")
113
+
114
+ return DisplacementResult(
115
+ inserted=False,
116
+ reason=f"insufficient frequency: {fact.observation_count} <= "
117
+ f"{least.observation_count} * {self.displacement_threshold}",
118
+ )
119
+
120
+ def purge_by_provenance(self, contract_id: str) -> int:
121
+ """Remove carrier facts whose provenance includes contract_id."""
122
+ purged = 0
123
+ for slots in self._slots.values():
124
+ before = len(slots)
125
+ slots[:] = [f for f in slots if contract_id not in f.provenance]
126
+ purged += before - len(slots)
127
+ return purged
128
+
129
+
130
+ def epistemic_contradiction_veto(
131
+ local_knowledge: Callable[[str, Any], bool],
132
+ ) -> Callable[[CarrierFact], bool]:
133
+ """Create a predicate that checks carrier facts against local knowledge."""
134
+
135
+ def _check(fact: CarrierFact) -> bool:
136
+ return local_knowledge(fact.source_domain, fact.labeled.value)
137
+
138
+ return _check
agentgov/consent.py ADDED
@@ -0,0 +1,316 @@
1
+ """Consent contract management for information flow governance.
2
+
3
+ Provides contract loading, validation, and enforcement at data ingestion
4
+ boundaries. Any data pathway handling person data must call
5
+ contract_check() before persisting state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ class ConsentContractLoadError(Exception):
23
+ """Raised when a contract YAML file fails to parse in strict mode."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ConsentContract:
28
+ """A bilateral consent agreement between operator and subject.
29
+
30
+ Immutable once loaded. Revocation creates a new record, it does not
31
+ mutate the existing contract.
32
+ """
33
+
34
+ id: str
35
+ parties: tuple[str, str]
36
+ scope: frozenset[str]
37
+ direction: str = "one_way"
38
+ visibility_mechanism: str = "on_request"
39
+ created_at: str = ""
40
+ revoked_at: str | None = None
41
+ principal_class: str = ""
42
+ guardian: str | None = None
43
+
44
+ @property
45
+ def active(self) -> bool:
46
+ return self.revoked_at is None
47
+
48
+
49
+ @dataclass
50
+ class ConsentRegistry:
51
+ """Runtime registry of consent contracts.
52
+
53
+ Loaded from YAML files on disk. Provides contract_check() for
54
+ ingestion boundary enforcement.
55
+ """
56
+
57
+ _contracts: dict[str, ConsentContract] = field(default_factory=dict)
58
+ _fail_closed: bool = field(default=False)
59
+ _loaded_at: float = field(default=0.0)
60
+ _contracts_dir: Path | None = field(default=None)
61
+
62
+ @property
63
+ def fail_closed(self) -> bool:
64
+ return self._fail_closed
65
+
66
+ def is_stale(self, stale_threshold_s: float = 300.0) -> bool:
67
+ """Check if the registry was loaded too long ago to trust."""
68
+ if self._loaded_at == 0.0:
69
+ return False
70
+ return time.time() - self._loaded_at > stale_threshold_s
71
+
72
+ def load(self, contracts_dir: Path | None = None, *, strict: bool = False) -> int:
73
+ """Load all contract files from the contracts directory.
74
+
75
+ Args:
76
+ contracts_dir: Path to scan for YAML contract files.
77
+ strict: When True, raise ConsentContractLoadError on any
78
+ malformed YAML instead of logging and skipping.
79
+
80
+ Returns:
81
+ The number of active contracts loaded.
82
+ """
83
+ directory = contracts_dir or self._contracts_dir
84
+ if directory is None:
85
+ log.info("No contracts directory configured")
86
+ self._fail_closed = True
87
+ return 0
88
+
89
+ try:
90
+ if not directory.exists():
91
+ log.info("No contracts directory at %s", directory)
92
+ self._fail_closed = True
93
+ return 0
94
+
95
+ count = 0
96
+ for path in sorted(directory.glob("*.yaml")):
97
+ try:
98
+ data = yaml.safe_load(path.read_text())
99
+ if data is None:
100
+ continue
101
+ contract = parse_contract(data)
102
+ self._contracts[contract.id] = contract
103
+ if contract.active:
104
+ count += 1
105
+ log.info(
106
+ "Loaded contract %s: %s <-> %s (scope: %s)",
107
+ contract.id,
108
+ contract.parties[0],
109
+ contract.parties[1],
110
+ ", ".join(sorted(contract.scope)),
111
+ )
112
+ except Exception as exc:
113
+ if strict:
114
+ raise ConsentContractLoadError(
115
+ f"Failed to load contract from {path}: {exc}"
116
+ ) from exc
117
+ log.exception("Failed to load contract from %s", path)
118
+
119
+ self._fail_closed = False
120
+ self._loaded_at = time.time()
121
+ return count
122
+ except ConsentContractLoadError:
123
+ raise
124
+ except Exception:
125
+ log.exception("Failed to load contracts from %s", directory)
126
+ self._fail_closed = True
127
+ return 0
128
+
129
+ def get(self, contract_id: str) -> ConsentContract | None:
130
+ return self._contracts.get(contract_id)
131
+
132
+ def __iter__(self):
133
+ return iter(self._contracts.values())
134
+
135
+ def contract_check(self, person_id: str, data_category: str) -> bool:
136
+ """Check whether an active contract permits this data flow.
137
+
138
+ Returns True if an active contract exists for the given person
139
+ with the given data category in scope. Returns False otherwise.
140
+ """
141
+ if self._fail_closed or self.is_stale():
142
+ return False
143
+ for contract in self._contracts.values():
144
+ if not contract.active:
145
+ continue
146
+ if person_id in contract.parties and data_category in contract.scope:
147
+ return True
148
+ return False
149
+
150
+ def get_contract_for(self, person_id: str) -> ConsentContract | None:
151
+ """Return the active contract for a person, if any."""
152
+ for contract in self._contracts.values():
153
+ if contract.active and person_id in contract.parties:
154
+ return contract
155
+ return None
156
+
157
+ def subject_data_categories(self, person_id: str) -> frozenset[str]:
158
+ """Return all permitted data categories for a person."""
159
+ categories: set[str] = set()
160
+ for contract in self._contracts.values():
161
+ if contract.active and person_id in contract.parties:
162
+ categories |= contract.scope
163
+ return frozenset(categories)
164
+
165
+ def revoke_contract(
166
+ self,
167
+ contract_id: str,
168
+ *,
169
+ contracts_dir: Path | None = None,
170
+ ) -> float:
171
+ """Revoke a single consent contract by ID.
172
+
173
+ Returns the wall-clock seconds the revocation took.
174
+ Raises KeyError if the contract_id is not registered.
175
+ """
176
+ t0 = time.monotonic()
177
+ contract = self._contracts.get(contract_id)
178
+ if contract is None:
179
+ raise KeyError(f"Contract {contract_id} not registered")
180
+
181
+ now_iso = datetime.now().isoformat()
182
+ revoked_contract = ConsentContract(
183
+ id=contract.id,
184
+ parties=contract.parties,
185
+ scope=contract.scope,
186
+ direction=contract.direction,
187
+ visibility_mechanism=contract.visibility_mechanism,
188
+ created_at=contract.created_at,
189
+ revoked_at=now_iso,
190
+ principal_class=contract.principal_class,
191
+ guardian=contract.guardian,
192
+ )
193
+ self._contracts[contract_id] = revoked_contract
194
+
195
+ directory = contracts_dir or self._contracts_dir
196
+ if directory is not None:
197
+ src = directory / f"{contract_id}.yaml"
198
+ if src.exists():
199
+ revoked_dir = directory / "revoked"
200
+ revoked_dir.mkdir(parents=True, exist_ok=True)
201
+ stamp = now_iso[:10]
202
+ dst = revoked_dir / f"{stamp}-{contract_id}.yaml"
203
+ n = 2
204
+ while dst.exists():
205
+ dst = revoked_dir / f"{stamp}-{contract_id}-{n}.yaml"
206
+ n += 1
207
+ src.rename(dst)
208
+ log.info("Revoked contract %s — moved YAML to %s", contract_id, dst)
209
+
210
+ elapsed = time.monotonic() - t0
211
+ return elapsed
212
+
213
+ def purge_subject(self, person_id: str) -> list[str]:
214
+ """Mark all contracts for a person as revoked. Returns revoked IDs."""
215
+ revoked: list[str] = []
216
+ for contract_id, contract in self._contracts.items():
217
+ if contract.active and person_id in contract.parties:
218
+ revoked_contract = ConsentContract(
219
+ id=contract.id,
220
+ parties=contract.parties,
221
+ scope=contract.scope,
222
+ direction=contract.direction,
223
+ visibility_mechanism=contract.visibility_mechanism,
224
+ created_at=contract.created_at,
225
+ revoked_at=datetime.now().isoformat(),
226
+ principal_class=contract.principal_class,
227
+ guardian=contract.guardian,
228
+ )
229
+ self._contracts[contract_id] = revoked_contract
230
+ revoked.append(contract_id)
231
+ log.info("Revoked contract %s for %s", contract_id, person_id)
232
+ return revoked
233
+
234
+ def create_contract(
235
+ self,
236
+ person_id: str,
237
+ scope: frozenset[str],
238
+ *,
239
+ contract_id: str | None = None,
240
+ direction: str = "one_way",
241
+ visibility_mechanism: str = "on_request",
242
+ contracts_dir: Path | None = None,
243
+ ) -> ConsentContract:
244
+ """Create and activate a new consent contract at runtime."""
245
+ now = datetime.now().isoformat()
246
+ cid = contract_id or f"contract-{person_id}-{now[:10]}"
247
+
248
+ contract = ConsentContract(
249
+ id=cid,
250
+ parties=("operator", person_id),
251
+ scope=scope,
252
+ direction=direction,
253
+ visibility_mechanism=visibility_mechanism,
254
+ created_at=now,
255
+ )
256
+
257
+ directory = contracts_dir or self._contracts_dir
258
+ if directory is not None:
259
+ directory.mkdir(parents=True, exist_ok=True)
260
+ contract_path = directory / f"{cid}.yaml"
261
+ contract_data: dict[str, Any] = {
262
+ "id": contract.id,
263
+ "parties": list(contract.parties),
264
+ "scope": sorted(contract.scope),
265
+ "direction": contract.direction,
266
+ "visibility_mechanism": contract.visibility_mechanism,
267
+ "created_at": contract.created_at,
268
+ }
269
+ if contract.principal_class:
270
+ contract_data["principal_class"] = contract.principal_class
271
+ if contract.guardian:
272
+ contract_data["guardian"] = contract.guardian
273
+ contract_path.write_text(yaml.dump(contract_data, default_flow_style=False))
274
+ log.info("Created consent contract %s for %s at %s", cid, person_id, contract_path)
275
+
276
+ self._contracts[cid] = contract
277
+ return contract
278
+
279
+ @property
280
+ def active_contracts(self) -> list[ConsentContract]:
281
+ return [c for c in self._contracts.values() if c.active]
282
+
283
+
284
+ def parse_contract(data: dict[str, Any]) -> ConsentContract:
285
+ """Parse a contract YAML dict into a ConsentContract."""
286
+ parties = data.get("parties", [])
287
+ if len(parties) != 2:
288
+ raise ValueError(f"Contract must have exactly 2 parties, got {len(parties)}")
289
+
290
+ return ConsentContract(
291
+ id=data["id"],
292
+ parties=(parties[0], parties[1]),
293
+ scope=frozenset(data.get("scope", [])),
294
+ direction=data.get("direction", "one_way"),
295
+ visibility_mechanism=data.get("visibility_mechanism", "on_request"),
296
+ created_at=data.get("created_at", ""),
297
+ revoked_at=data.get("revoked_at"),
298
+ principal_class=data.get("principal_class", ""),
299
+ guardian=data.get("guardian"),
300
+ )
301
+
302
+
303
+ def load_contracts(contracts_dir: Path | None = None) -> ConsentRegistry:
304
+ """Convenience function: create and load a ConsentRegistry."""
305
+ registry = ConsentRegistry(_contracts_dir=contracts_dir)
306
+ registry.load(contracts_dir)
307
+ return registry
308
+
309
+
310
+ def check_consent_state_freshness(path: Path, *, stale_threshold_s: float = 300.0) -> bool:
311
+ """Check if a consent state file on disk is fresh enough to trust."""
312
+ try:
313
+ mtime = path.stat().st_mtime
314
+ return (time.time() - mtime) < stale_threshold_s
315
+ except OSError:
316
+ return False