crprotocol 2.0.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 (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,662 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Privacy controls — data classification, PII detection, retention, erasure (§7.12).
4
+
5
+ Implements:
6
+ - Data sensitivity classification (PUBLIC → CRITICAL)
7
+ - PII detection with configurable patterns
8
+ - Data minimization enforcement
9
+ - Retention policies with automatic expiry
10
+ - Right to erasure (GDPR Article 17 / EU AI Act transparency)
11
+ - Data lineage tracking
12
+
13
+ EU AI Act: Art. 10 (data governance), Art. 12 (record-keeping), Art. 13 (transparency)
14
+ ISO 42001: A.6.2.4 (impact assessment), A.6.2.6 (data management), A.6.2.7 (data subject rights)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import logging
21
+ import re
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from enum import IntEnum
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger("crp.security.privacy")
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Data classification levels
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class DataClassification(IntEnum):
36
+ """Data sensitivity classification (§7.12.1).
37
+
38
+ Higher values = more sensitive. Controls encryption, retention,
39
+ access requirements, and audit verbosity.
40
+ """
41
+
42
+ PUBLIC = 0 # Non-sensitive: documentation, public facts
43
+ INTERNAL = 1 # Internal business context (default for all ingested text)
44
+ CONFIDENTIAL = 2 # Contains business-sensitive information
45
+ RESTRICTED = 3 # Contains PII or regulated data
46
+ CRITICAL = 4 # Contains credentials, secrets, or highly sensitive PII
47
+
48
+
49
+ # Classification → minimum security requirements
50
+ _CLASSIFICATION_REQUIREMENTS: dict[DataClassification, dict[str, Any]] = {
51
+ DataClassification.PUBLIC: {
52
+ "encrypt_at_rest": False,
53
+ "max_retention_hours": 0, # 0 = no limit
54
+ "audit_access": False,
55
+ "require_consent": False,
56
+ "allow_export": True,
57
+ },
58
+ DataClassification.INTERNAL: {
59
+ "encrypt_at_rest": True,
60
+ "max_retention_hours": 720, # 30 days
61
+ "audit_access": False,
62
+ "require_consent": False,
63
+ "allow_export": True,
64
+ },
65
+ DataClassification.CONFIDENTIAL: {
66
+ "encrypt_at_rest": True,
67
+ "max_retention_hours": 168, # 7 days
68
+ "audit_access": True,
69
+ "require_consent": False,
70
+ "allow_export": True,
71
+ },
72
+ DataClassification.RESTRICTED: {
73
+ "encrypt_at_rest": True,
74
+ "max_retention_hours": 24, # 24 hours
75
+ "audit_access": True,
76
+ "require_consent": True,
77
+ "allow_export": False, # Must be explicitly authorized
78
+ },
79
+ DataClassification.CRITICAL: {
80
+ "encrypt_at_rest": True,
81
+ "max_retention_hours": 1, # 1 hour — process and discard
82
+ "audit_access": True,
83
+ "require_consent": True,
84
+ "allow_export": False,
85
+ },
86
+ }
87
+
88
+
89
+ def classification_requirements(level: DataClassification) -> dict[str, Any]:
90
+ """Return minimum security requirements for a data classification level."""
91
+ return _CLASSIFICATION_REQUIREMENTS[level].copy()
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # PII detection patterns
96
+ # ---------------------------------------------------------------------------
97
+
98
+ # Compiled regex patterns for common PII types.
99
+ # These are intentionally conservative (high precision, lower recall)
100
+ # to avoid false positives that would degrade user experience.
101
+ _PII_PATTERNS: list[tuple[str, str, re.Pattern[str]]] = [
102
+ (
103
+ "email",
104
+ "Email address",
105
+ re.compile(
106
+ r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b",
107
+ ),
108
+ ),
109
+ (
110
+ "phone_international",
111
+ "International phone number",
112
+ re.compile(
113
+ r"(?<!\d)\+\d{1,3}[\s\-]?\(?\d{1,4}\)?[\s\-]?\d{3,4}[\s\-]?\d{3,4}(?!\d)",
114
+ ),
115
+ ),
116
+ (
117
+ "credit_card",
118
+ "Credit card number",
119
+ re.compile(
120
+ r"\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))"
121
+ r"[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b",
122
+ ),
123
+ ),
124
+ (
125
+ "ssn_us",
126
+ "US Social Security Number",
127
+ re.compile(
128
+ r"\b\d{3}[\s\-]\d{2}[\s\-]\d{4}\b",
129
+ ),
130
+ ),
131
+ (
132
+ "ip_address",
133
+ "IP address (v4)",
134
+ re.compile(
135
+ r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
136
+ r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b",
137
+ ),
138
+ ),
139
+ (
140
+ "iban",
141
+ "International Bank Account Number",
142
+ re.compile(
143
+ r"\b[A-Z]{2}\d{2}[\s]?[A-Z0-9]{4}[\s]?(?:[A-Z0-9]{4}[\s]?){2,7}[A-Z0-9]{1,4}\b",
144
+ ),
145
+ ),
146
+ (
147
+ "passport",
148
+ "Passport number pattern",
149
+ re.compile(
150
+ r"\b[A-Z]{1,2}\d{6,9}\b",
151
+ ),
152
+ ),
153
+ (
154
+ "api_key_generic",
155
+ "Generic API key / secret",
156
+ re.compile(
157
+ r"\b(?:sk|pk|api|key|secret|token|bearer)[_\-]"
158
+ r"[a-zA-Z0-9]{20,}\b",
159
+ re.IGNORECASE,
160
+ ),
161
+ ),
162
+ (
163
+ "aws_key",
164
+ "AWS access key",
165
+ re.compile(
166
+ r"\bAKIA[0-9A-Z]{16}\b",
167
+ ),
168
+ ),
169
+ ]
170
+
171
+
172
+ @dataclass
173
+ class PIIDetection:
174
+ """Result of PII scan on a piece of text."""
175
+
176
+ pii_type: str # e.g. "email", "credit_card"
177
+ description: str # Human-readable
178
+ position: int # Character offset
179
+ length: int # Length of matched text
180
+ text_hash: str # SHA-256 hash of matched text (not the text itself)
181
+
182
+
183
+ @dataclass
184
+ class PIIScanResult:
185
+ """Aggregate result of PII scanning."""
186
+
187
+ detections: list[PIIDetection] = field(default_factory=list)
188
+ scanned_length: int = 0
189
+ scan_time_ms: float = 0.0
190
+
191
+ @property
192
+ def has_pii(self) -> bool:
193
+ return len(self.detections) > 0
194
+
195
+ @property
196
+ def pii_types_found(self) -> set[str]:
197
+ return {d.pii_type for d in self.detections}
198
+
199
+ @property
200
+ def highest_classification(self) -> DataClassification:
201
+ """Return the highest data classification implied by detected PII."""
202
+ if not self.detections:
203
+ return DataClassification.INTERNAL
204
+ types = self.pii_types_found
205
+ if types & {"credit_card", "ssn_us", "aws_key", "api_key_generic"}:
206
+ return DataClassification.CRITICAL
207
+ if types & {"iban", "passport"}:
208
+ return DataClassification.RESTRICTED
209
+ if types & {"email", "phone_international", "ip_address"}:
210
+ return DataClassification.CONFIDENTIAL
211
+ return DataClassification.INTERNAL
212
+
213
+ def to_dict(self) -> dict[str, Any]:
214
+ return {
215
+ "has_pii": self.has_pii,
216
+ "detection_count": len(self.detections),
217
+ "pii_types": sorted(self.pii_types_found),
218
+ "highest_classification": self.highest_classification.name,
219
+ "scanned_length": self.scanned_length,
220
+ "scan_time_ms": round(self.scan_time_ms, 2),
221
+ }
222
+
223
+
224
+ class PIIScanner:
225
+ """Detect PII patterns in text (§7.12.2).
226
+
227
+ IMPORTANT: This scanner is advisory. It NEVER modifies, redacts, or blocks
228
+ content (Axiom 9 — output integrity). It reports findings for the user
229
+ to act upon.
230
+
231
+ Usage::
232
+
233
+ scanner = PIIScanner()
234
+ result = scanner.scan("Contact john@example.com for details.")
235
+ if result.has_pii:
236
+ print(f"Found PII: {result.pii_types_found}")
237
+ """
238
+
239
+ def __init__(
240
+ self,
241
+ *,
242
+ extra_patterns: list[tuple[str, str, re.Pattern[str]]] | None = None,
243
+ disabled_types: set[str] | None = None,
244
+ ) -> None:
245
+ self._patterns = list(_PII_PATTERNS)
246
+ if extra_patterns:
247
+ self._patterns.extend(extra_patterns)
248
+ self._disabled = disabled_types or set()
249
+
250
+ def scan(self, text: str) -> PIIScanResult:
251
+ """Scan text for PII patterns.
252
+
253
+ Returns PIIScanResult with detections. Text of matches is NEVER
254
+ stored — only SHA-256 hashes for audit trail purposes.
255
+ """
256
+ t0 = time.monotonic()
257
+ detections: list[PIIDetection] = []
258
+
259
+ for pii_type, description, pattern in self._patterns:
260
+ if pii_type in self._disabled:
261
+ continue
262
+ for match in pattern.finditer(text):
263
+ matched_text = match.group()
264
+ detections.append(
265
+ PIIDetection(
266
+ pii_type=pii_type,
267
+ description=description,
268
+ position=match.start(),
269
+ length=len(matched_text),
270
+ # Hash the matched text — NEVER store raw PII
271
+ text_hash=hashlib.sha256(
272
+ matched_text.encode("utf-8")
273
+ ).hexdigest()[:16],
274
+ )
275
+ )
276
+
277
+ elapsed_ms = (time.monotonic() - t0) * 1000
278
+
279
+ if detections:
280
+ logger.info(
281
+ "PII scan: %d detections across %d types in %d chars (%.1fms)",
282
+ len(detections),
283
+ len({d.pii_type for d in detections}),
284
+ len(text),
285
+ elapsed_ms,
286
+ )
287
+
288
+ return PIIScanResult(
289
+ detections=detections,
290
+ scanned_length=len(text),
291
+ scan_time_ms=elapsed_ms,
292
+ )
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Data retention policy
297
+ # ---------------------------------------------------------------------------
298
+
299
+
300
+ @dataclass
301
+ class RetentionPolicy:
302
+ """Data retention policy configuration (§7.12.3).
303
+
304
+ Defines how long different classifications of data are retained
305
+ and what happens at expiry.
306
+ """
307
+
308
+ default_retention_hours: float = 720.0 # 30 days
309
+ restricted_retention_hours: float = 24.0 # 24 hours
310
+ critical_retention_hours: float = 1.0 # 1 hour
311
+ auto_purge: bool = True # Automatically delete expired data
312
+ purge_method: str = "zero_fill" # "zero_fill" or "delete"
313
+
314
+
315
+ @dataclass
316
+ class RetentionRecord:
317
+ """Tracks retention status for a piece of data."""
318
+
319
+ data_id: str
320
+ classification: DataClassification
321
+ created_at: float
322
+ expires_at: float
323
+ source_label: str = ""
324
+ purged: bool = False
325
+ purged_at: float = 0.0
326
+
327
+
328
+ class RetentionManager:
329
+ """Manages data retention and automatic purging (§7.12.3).
330
+
331
+ EU AI Act Art. 12: Record-keeping with defined retention periods.
332
+ ISO 42001 A.6.2.8: Records management with lifecycle tracking.
333
+
334
+ Usage::
335
+
336
+ rm = RetentionManager()
337
+ rm.register("fact-123", DataClassification.RESTRICTED, "user-input")
338
+ expired = rm.get_expired() # Returns IDs ready for purging
339
+ rm.mark_purged("fact-123")
340
+ """
341
+
342
+ def __init__(self, policy: RetentionPolicy | None = None) -> None:
343
+ self._policy = policy or RetentionPolicy()
344
+ self._records: dict[str, RetentionRecord] = {}
345
+
346
+ @property
347
+ def tracked_count(self) -> int:
348
+ return len(self._records)
349
+
350
+ @property
351
+ def active_count(self) -> int:
352
+ return sum(1 for r in self._records.values() if not r.purged)
353
+
354
+ def _retention_hours(self, classification: DataClassification) -> float:
355
+ """Get retention hours for a classification level."""
356
+ req = _CLASSIFICATION_REQUIREMENTS.get(classification, {})
357
+ max_hours = req.get("max_retention_hours", 0)
358
+ if max_hours > 0:
359
+ return float(max_hours)
360
+ return self._policy.default_retention_hours
361
+
362
+ def register(
363
+ self,
364
+ data_id: str,
365
+ classification: DataClassification,
366
+ source_label: str = "",
367
+ ) -> RetentionRecord:
368
+ """Register a data item for retention tracking."""
369
+ now = time.time()
370
+ hours = self._retention_hours(classification)
371
+ expires_at = now + (hours * 3600) if hours > 0 else 0.0
372
+
373
+ record = RetentionRecord(
374
+ data_id=data_id,
375
+ classification=classification,
376
+ created_at=now,
377
+ expires_at=expires_at,
378
+ source_label=source_label,
379
+ )
380
+ self._records[data_id] = record
381
+ return record
382
+
383
+ def get_expired(self) -> list[str]:
384
+ """Return IDs of all expired, non-purged data items."""
385
+ now = time.time()
386
+ return [
387
+ rid
388
+ for rid, rec in self._records.items()
389
+ if not rec.purged and rec.expires_at > 0 and now > rec.expires_at
390
+ ]
391
+
392
+ def mark_purged(self, data_id: str) -> bool:
393
+ """Mark a data item as purged. Returns True if found."""
394
+ rec = self._records.get(data_id)
395
+ if rec is None:
396
+ return False
397
+ rec.purged = True
398
+ rec.purged_at = time.time()
399
+ return True
400
+
401
+ def enforce(self) -> list[str]:
402
+ """Run retention enforcement: return IDs of newly expired items.
403
+
404
+ The caller is responsible for actually deleting the data.
405
+ This method only identifies what needs purging.
406
+ """
407
+ expired = self.get_expired()
408
+ if expired:
409
+ logger.info(
410
+ "Retention enforcement: %d items expired and pending purge",
411
+ len(expired),
412
+ )
413
+ return expired
414
+
415
+ def get_record(self, data_id: str) -> RetentionRecord | None:
416
+ """Get retention record for a data item."""
417
+ return self._records.get(data_id)
418
+
419
+ def to_dict(self) -> dict[str, Any]:
420
+ """Export retention state for audit/compliance reporting."""
421
+ return {
422
+ "tracked_count": self.tracked_count,
423
+ "active_count": self.active_count,
424
+ "purged_count": self.tracked_count - self.active_count,
425
+ "policy": {
426
+ "default_retention_hours": self._policy.default_retention_hours,
427
+ "restricted_retention_hours": self._policy.restricted_retention_hours,
428
+ "critical_retention_hours": self._policy.critical_retention_hours,
429
+ "auto_purge": self._policy.auto_purge,
430
+ "purge_method": self._policy.purge_method,
431
+ },
432
+ }
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Right to erasure (GDPR Article 17)
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ @dataclass
441
+ class ErasureRequest:
442
+ """Tracks a right-to-erasure (data deletion) request."""
443
+
444
+ request_id: str
445
+ requester_hash: str # SHA-256 hash of requester identifier
446
+ requested_at: float = field(default_factory=time.time)
447
+ scope: str = "session" # "session" | "all" | "specific_facts"
448
+ target_ids: list[str] = field(default_factory=list)
449
+ completed: bool = False
450
+ completed_at: float = 0.0
451
+ items_erased: int = 0
452
+
453
+
454
+ class ErasureManager:
455
+ """Handles right-to-erasure requests (GDPR Article 17) (§7.12.4).
456
+
457
+ Tracks erasure requests, ensures they are fulfilled, and maintains
458
+ an audit trail of what was deleted and when.
459
+
460
+ Usage::
461
+
462
+ em = ErasureManager()
463
+ req = em.create_request("user-hash-abc", scope="session")
464
+ # ... caller erases the actual data ...
465
+ em.complete_request(req.request_id, items_erased=42)
466
+ """
467
+
468
+ def __init__(self) -> None:
469
+ self._requests: dict[str, ErasureRequest] = {}
470
+
471
+ def create_request(
472
+ self,
473
+ requester_hash: str,
474
+ scope: str = "session",
475
+ target_ids: list[str] | None = None,
476
+ ) -> ErasureRequest:
477
+ """Create a new erasure request.
478
+
479
+ Args:
480
+ requester_hash: SHA-256 hash of the requester's identity.
481
+ scope: "session" (all data in current session),
482
+ "all" (all data across sessions),
483
+ "specific_facts" (only listed fact IDs).
484
+ target_ids: Specific fact IDs when scope="specific_facts".
485
+
486
+ Returns:
487
+ ErasureRequest with a unique request_id.
488
+ """
489
+ import uuid
490
+
491
+ request_id = f"erasure-{uuid.uuid4().hex[:12]}"
492
+ req = ErasureRequest(
493
+ request_id=request_id,
494
+ requester_hash=requester_hash,
495
+ scope=scope,
496
+ target_ids=target_ids or [],
497
+ )
498
+ self._requests[request_id] = req
499
+
500
+ logger.info(
501
+ "Erasure request created: %s (scope=%s, targets=%d)",
502
+ request_id,
503
+ scope,
504
+ len(req.target_ids),
505
+ )
506
+ return req
507
+
508
+ def complete_request(
509
+ self,
510
+ request_id: str,
511
+ items_erased: int = 0,
512
+ ) -> bool:
513
+ """Mark an erasure request as completed.
514
+
515
+ Returns True if request was found and completed.
516
+ """
517
+ req = self._requests.get(request_id)
518
+ if req is None:
519
+ return False
520
+ req.completed = True
521
+ req.completed_at = time.time()
522
+ req.items_erased = items_erased
523
+
524
+ logger.info(
525
+ "Erasure request completed: %s (%d items erased in %.1fs)",
526
+ request_id,
527
+ items_erased,
528
+ req.completed_at - req.requested_at,
529
+ )
530
+ return True
531
+
532
+ def pending_requests(self) -> list[ErasureRequest]:
533
+ """Return all pending (incomplete) erasure requests."""
534
+ return [r for r in self._requests.values() if not r.completed]
535
+
536
+ def to_dict(self) -> dict[str, Any]:
537
+ """Export erasure state for audit/compliance reporting."""
538
+ return {
539
+ "total_requests": len(self._requests),
540
+ "completed": sum(1 for r in self._requests.values() if r.completed),
541
+ "pending": sum(1 for r in self._requests.values() if not r.completed),
542
+ "total_items_erased": sum(
543
+ r.items_erased for r in self._requests.values() if r.completed
544
+ ),
545
+ "requests": [
546
+ {
547
+ "request_id": r.request_id,
548
+ "scope": r.scope,
549
+ "completed": r.completed,
550
+ "items_erased": r.items_erased,
551
+ "requested_at": r.requested_at,
552
+ "completed_at": r.completed_at if r.completed else None,
553
+ }
554
+ for r in self._requests.values()
555
+ ],
556
+ }
557
+
558
+
559
+ # ---------------------------------------------------------------------------
560
+ # Data lineage tracking
561
+ # ---------------------------------------------------------------------------
562
+
563
+
564
+ @dataclass
565
+ class DataLineageEntry:
566
+ """Tracks the origin and transformations of a piece of data."""
567
+
568
+ data_id: str
569
+ origin: str # "ingest", "extraction", "dispatch_output", "share"
570
+ source_label: str = ""
571
+ classification: DataClassification = DataClassification.INTERNAL
572
+ created_at: float = field(default_factory=time.time)
573
+ transformations: list[str] = field(default_factory=list)
574
+ parent_ids: list[str] = field(default_factory=list)
575
+
576
+
577
+ class DataLineageTracker:
578
+ """Track data provenance and transformation history (§7.12.5).
579
+
580
+ ISO 42001 A.6.2.6: Data management requires tracking data origin,
581
+ quality, and transformations throughout the AI lifecycle.
582
+
583
+ Usage::
584
+
585
+ tracker = DataLineageTracker()
586
+ tracker.record("fact-1", "extraction", "user-input", DataClassification.INTERNAL)
587
+ tracker.add_transformation("fact-1", "quarantine_promoted")
588
+ """
589
+
590
+ def __init__(self) -> None:
591
+ self._entries: dict[str, DataLineageEntry] = {}
592
+
593
+ def record(
594
+ self,
595
+ data_id: str,
596
+ origin: str,
597
+ source_label: str = "",
598
+ classification: DataClassification = DataClassification.INTERNAL,
599
+ parent_ids: list[str] | None = None,
600
+ ) -> DataLineageEntry:
601
+ """Record a new data lineage entry."""
602
+ entry = DataLineageEntry(
603
+ data_id=data_id,
604
+ origin=origin,
605
+ source_label=source_label,
606
+ classification=classification,
607
+ parent_ids=parent_ids or [],
608
+ )
609
+ self._entries[data_id] = entry
610
+ return entry
611
+
612
+ def add_transformation(self, data_id: str, transformation: str) -> bool:
613
+ """Record a transformation applied to data. Returns True if found."""
614
+ entry = self._entries.get(data_id)
615
+ if entry is None:
616
+ return False
617
+ entry.transformations.append(transformation)
618
+ return True
619
+
620
+ def reclassify(
621
+ self, data_id: str, new_classification: DataClassification
622
+ ) -> bool:
623
+ """Update classification level for a data item. Returns True if found."""
624
+ entry = self._entries.get(data_id)
625
+ if entry is None:
626
+ return False
627
+ old = entry.classification
628
+ entry.classification = new_classification
629
+ entry.transformations.append(
630
+ f"reclassified:{old.name}->{new_classification.name}"
631
+ )
632
+ return True
633
+
634
+ def get_lineage(self, data_id: str) -> DataLineageEntry | None:
635
+ """Get lineage entry for a data item."""
636
+ return self._entries.get(data_id)
637
+
638
+ def get_by_classification(
639
+ self, level: DataClassification
640
+ ) -> list[DataLineageEntry]:
641
+ """Get all entries at or above a classification level."""
642
+ return [e for e in self._entries.values() if e.classification >= level]
643
+
644
+ def to_dict(self) -> dict[str, Any]:
645
+ """Export lineage state for audit/compliance reporting."""
646
+ by_class = {}
647
+ for entry in self._entries.values():
648
+ name = entry.classification.name
649
+ by_class[name] = by_class.get(name, 0) + 1
650
+
651
+ return {
652
+ "total_tracked": len(self._entries),
653
+ "by_classification": by_class,
654
+ "by_origin": self._count_by_field("origin"),
655
+ }
656
+
657
+ def _count_by_field(self, field_name: str) -> dict[str, int]:
658
+ counts: dict[str, int] = {}
659
+ for entry in self._entries.values():
660
+ val = getattr(entry, field_name, "unknown")
661
+ counts[val] = counts.get(val, 0) + 1
662
+ return counts