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,249 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Ingest quarantine — anti-poisoning with 1-window quarantine (§7.8).
4
+
5
+ Facts from untrusted sources are quarantined for 1 window with a 0.7×
6
+ confidence penalty. Cross-reference validation promotes or rejects them.
7
+ Batch poisoning detection: >30% failures → quarantine entire batch.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ @dataclass
17
+ class QuarantineEntry:
18
+ """A fact held in quarantine."""
19
+
20
+ fact_id: str
21
+ original_confidence: float
22
+ penalized_confidence: float # 0.7× original
23
+ quarantine_window_id: str
24
+ ingested_at: float = field(default_factory=time.time)
25
+ source_label: str = ""
26
+ promoted: bool = False
27
+ rejected: bool = False
28
+ rejection_reason: str = ""
29
+
30
+
31
+ @dataclass
32
+ class QuarantineReport:
33
+ """Result of cross-reference validation pass."""
34
+
35
+ total_quarantined: int = 0
36
+ promoted: int = 0
37
+ rejected: int = 0
38
+ batch_poisoned: bool = False
39
+ details: list[str] = field(default_factory=list)
40
+
41
+
42
+ # Confidence penalty factor for quarantined facts (§6F.1)
43
+ QUARANTINE_CONFIDENCE_FACTOR = 0.7
44
+ # Batch poisoning threshold: >30% failures → quarantine entire batch (§6F.3)
45
+ BATCH_POISON_THRESHOLD = 0.30
46
+
47
+
48
+ class IngestQuarantine:
49
+ """1-window quarantine with confidence penalty and batch poisoning detection (§7.8).
50
+
51
+ Workflow:
52
+ 1. Incoming facts go into quarantine with 0.7× confidence
53
+ 2. After 1 window, cross-reference against extraction-derived facts
54
+ 3. Matching facts are promoted (confidence restored)
55
+ 4. Non-matching facts are rejected
56
+ 5. If >30% of a batch fails, quarantine entire batch
57
+
58
+ Usage:
59
+ q = IngestQuarantine()
60
+ q.quarantine_facts([...], "w-1", source="user_input")
61
+ # ... next window processes ...
62
+ report = q.validate_and_promote("w-2", extraction_fact_texts)
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ confidence_factor: float = QUARANTINE_CONFIDENCE_FACTOR,
68
+ batch_poison_threshold: float = BATCH_POISON_THRESHOLD,
69
+ ) -> None:
70
+ self._factor = confidence_factor
71
+ self._batch_threshold = batch_poison_threshold
72
+ self._quarantined: dict[str, QuarantineEntry] = {}
73
+ self._window_batches: dict[str, list[str]] = {} # window_id → [fact_ids]
74
+ self._fact_texts: dict[str, str] = {} # fact_id → original text for similarity
75
+ self._promotion_history: list[QuarantineReport] = []
76
+
77
+ @property
78
+ def quarantine_count(self) -> int:
79
+ """Number of facts currently in quarantine (not promoted or rejected)."""
80
+ return sum(
81
+ 1 for e in self._quarantined.values()
82
+ if not e.promoted and not e.rejected
83
+ )
84
+
85
+ @property
86
+ def history(self) -> list[QuarantineReport]:
87
+ return list(self._promotion_history)
88
+
89
+ def quarantine_fact(
90
+ self,
91
+ fact_id: str,
92
+ original_confidence: float,
93
+ window_id: str,
94
+ source_label: str = "",
95
+ fact_text: str = "",
96
+ ) -> QuarantineEntry:
97
+ """Place a single fact into quarantine with 0.7× confidence penalty (§6F.1)."""
98
+ entry = QuarantineEntry(
99
+ fact_id=fact_id,
100
+ original_confidence=original_confidence,
101
+ penalized_confidence=original_confidence * self._factor,
102
+ quarantine_window_id=window_id,
103
+ source_label=source_label,
104
+ )
105
+ self._quarantined[fact_id] = entry
106
+ if fact_text:
107
+ self._fact_texts[fact_id] = fact_text
108
+ self._window_batches.setdefault(window_id, []).append(fact_id)
109
+ return entry
110
+
111
+ def quarantine_facts(
112
+ self,
113
+ facts: list[tuple[str, float]] | list[tuple[str, float, str]],
114
+ window_id: str,
115
+ source_label: str = "",
116
+ ) -> list[QuarantineEntry]:
117
+ """Quarantine a batch of facts.
118
+
119
+ Facts can be (fact_id, confidence) or (fact_id, confidence, text).
120
+ """
121
+ entries = []
122
+ for item in facts:
123
+ if len(item) >= 3:
124
+ fid, conf, text = item[0], item[1], item[2]
125
+ else:
126
+ fid, conf, text = item[0], item[1], ""
127
+ entries.append(
128
+ self.quarantine_fact(fid, conf, window_id, source_label, text)
129
+ )
130
+ return entries
131
+
132
+ def get_penalized_confidence(self, fact_id: str) -> float | None:
133
+ """Get quarantine-penalized confidence for a fact."""
134
+ entry = self._quarantined.get(fact_id)
135
+ if entry is None:
136
+ return None
137
+ if entry.promoted:
138
+ return entry.original_confidence
139
+ return entry.penalized_confidence
140
+
141
+ def is_quarantined(self, fact_id: str) -> bool:
142
+ """Check if a fact is in active quarantine."""
143
+ entry = self._quarantined.get(fact_id)
144
+ if entry is None:
145
+ return False
146
+ return not entry.promoted and not entry.rejected
147
+
148
+ def validate_and_promote(
149
+ self,
150
+ current_window_id: str,
151
+ extraction_fact_texts: dict[str, str],
152
+ similarity_threshold: float = 0.5,
153
+ ) -> QuarantineReport:
154
+ """Cross-reference validation: promote or reject quarantined facts (§6F.2).
155
+
156
+ Facts quarantined in an earlier window are validated against
157
+ extraction-derived facts. Text overlap > threshold → promote.
158
+
159
+ Args:
160
+ current_window_id: Current window being processed
161
+ extraction_fact_texts: {fact_id: text} from extraction pipeline
162
+ similarity_threshold: Word overlap threshold for cross-reference
163
+
164
+ Returns:
165
+ QuarantineReport with promotion/rejection counts
166
+ """
167
+ report = QuarantineReport()
168
+ extraction_words_sets = {
169
+ fid: set(text.lower().split())
170
+ for fid, text in extraction_fact_texts.items()
171
+ }
172
+
173
+ # Check only facts quarantined in a PREVIOUS window
174
+ pending = [
175
+ (fid, entry) for fid, entry in self._quarantined.items()
176
+ if not entry.promoted and not entry.rejected
177
+ and entry.quarantine_window_id != current_window_id
178
+ ]
179
+
180
+ report.total_quarantined = len(pending)
181
+ promoted_count = 0
182
+ rejected_count = 0
183
+
184
+ for fid, entry in pending:
185
+ # Cross-reference against extraction facts via word overlap
186
+ matched = False
187
+ # Look up the quarantined fact's text for similarity comparison
188
+ q_text = self._fact_texts.get(fid, "")
189
+ q_words = set(q_text.lower().split()) if q_text else set()
190
+
191
+ for ext_fid, ext_words in extraction_words_sets.items():
192
+ # Exact ID match — same fact re-extracted
193
+ if ext_fid == fid:
194
+ matched = True
195
+ break
196
+ # Word-overlap similarity: Jaccard-like measure
197
+ if q_words and ext_words:
198
+ overlap = len(q_words & ext_words)
199
+ union = len(q_words | ext_words)
200
+ if union > 0 and (overlap / union) >= similarity_threshold:
201
+ matched = True
202
+ break
203
+
204
+ if matched:
205
+ entry.promoted = True
206
+ promoted_count += 1
207
+ report.details.append(f"promoted:{fid}")
208
+ else:
209
+ entry.rejected = True
210
+ entry.rejection_reason = "no_cross_reference"
211
+ rejected_count += 1
212
+ report.details.append(f"rejected:{fid}")
213
+
214
+ report.promoted = promoted_count
215
+ report.rejected = rejected_count
216
+
217
+ # Batch poisoning detection (§6F.3)
218
+ if report.total_quarantined > 0:
219
+ reject_ratio = rejected_count / report.total_quarantined
220
+ if reject_ratio > self._batch_threshold:
221
+ report.batch_poisoned = True
222
+ # Mark all promoted facts as rejected too
223
+ for _fid, entry in pending:
224
+ if entry.promoted:
225
+ entry.promoted = False
226
+ entry.rejected = True
227
+ entry.rejection_reason = "batch_poisoning"
228
+ report.promoted -= 1
229
+ report.rejected += 1
230
+ report.details.append(
231
+ f"batch_poisoned: {reject_ratio:.0%} > {self._batch_threshold:.0%}"
232
+ )
233
+
234
+ self._promotion_history.append(report)
235
+ return report
236
+
237
+ def get_active_entries(self) -> list[QuarantineEntry]:
238
+ """Return all currently quarantined (non-promoted, non-rejected) entries."""
239
+ return [
240
+ e for e in self._quarantined.values()
241
+ if not e.promoted and not e.rejected
242
+ ]
243
+
244
+ def clear(self) -> None:
245
+ """Clear all quarantine state."""
246
+ self._quarantined.clear()
247
+ self._window_batches.clear()
248
+ self._fact_texts.clear()
249
+ self._promotion_history.clear()
crp/security/rbac.py ADDED
@@ -0,0 +1,221 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """RBAC & rate limiting — role-based access control + per-session limits (§7.10).
4
+
5
+ Roles: OBSERVER (read-only), OPERATOR (dispatch + ingest), ADMIN (full).
6
+ Rate limits: 60 req/min dispatch, 100 MB/min ingest, per-session token cap.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections import deque
13
+ from dataclasses import dataclass, field
14
+ from enum import IntEnum
15
+
16
+
17
+ class Role(IntEnum):
18
+ """RBAC roles with increasing privilege (§6G.1)."""
19
+
20
+ OBSERVER = 0 # Read-only: inspect state, read facts
21
+ OPERATOR = 1 # Dispatch + ingest: all observer + dispatch, ingest
22
+ ADMIN = 2 # Full: all operator + config, delete, export
23
+
24
+
25
+ class Permission(str):
26
+ """Named permission strings."""
27
+
28
+ # Observer permissions
29
+ READ_FACTS = "read_facts"
30
+ READ_STATE = "read_state"
31
+ READ_ENVELOPE = "read_envelope"
32
+ READ_EVENTS = "read_events"
33
+
34
+ # Operator permissions
35
+ DISPATCH = "dispatch"
36
+ INGEST = "ingest"
37
+ STORE_FACTS = "store_facts"
38
+
39
+ # Admin permissions
40
+ DELETE_FACTS = "delete_facts"
41
+ EXPORT_STATE = "export_state"
42
+ CONFIGURE = "configure"
43
+ MANAGE_SESSIONS = "manage_sessions"
44
+
45
+
46
+ # Role → allowed permissions
47
+ _ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
48
+ Role.OBSERVER: frozenset({
49
+ Permission.READ_FACTS,
50
+ Permission.READ_STATE,
51
+ Permission.READ_ENVELOPE,
52
+ Permission.READ_EVENTS,
53
+ }),
54
+ Role.OPERATOR: frozenset({
55
+ Permission.READ_FACTS,
56
+ Permission.READ_STATE,
57
+ Permission.READ_ENVELOPE,
58
+ Permission.READ_EVENTS,
59
+ Permission.DISPATCH,
60
+ Permission.INGEST,
61
+ Permission.STORE_FACTS,
62
+ Permission.EXPORT_STATE,
63
+ Permission.CONFIGURE,
64
+ }),
65
+ Role.ADMIN: frozenset({
66
+ Permission.READ_FACTS,
67
+ Permission.READ_STATE,
68
+ Permission.READ_ENVELOPE,
69
+ Permission.READ_EVENTS,
70
+ Permission.DISPATCH,
71
+ Permission.INGEST,
72
+ Permission.STORE_FACTS,
73
+ Permission.DELETE_FACTS,
74
+ Permission.EXPORT_STATE,
75
+ Permission.CONFIGURE,
76
+ Permission.MANAGE_SESSIONS,
77
+ }),
78
+ }
79
+
80
+
81
+ @dataclass
82
+ class RateLimitConfig:
83
+ """Rate limiting configuration (§6G.2)."""
84
+
85
+ dispatch_per_minute: int = 60
86
+ ingest_mb_per_minute: float = 100.0
87
+ session_token_cap: int = 0 # 0 = unlimited
88
+
89
+
90
+ @dataclass
91
+ class RateLimitState:
92
+ """Sliding-window rate limit tracking."""
93
+
94
+ dispatch_timestamps: deque[float] = field(default_factory=deque)
95
+ ingest_bytes: deque[tuple[float, int]] = field(default_factory=deque)
96
+ session_tokens_used: int = 0
97
+
98
+
99
+ @dataclass
100
+ class AccessResult:
101
+ """Result of an RBAC check."""
102
+
103
+ allowed: bool
104
+ reason: str = ""
105
+ role: Role = Role.OBSERVER
106
+
107
+
108
+ class RBACEnforcer:
109
+ """Role-based access control + rate limiting (§7.10).
110
+
111
+ Usage:
112
+ rbac = RBACEnforcer(role=Role.OPERATOR)
113
+ result = rbac.check_permission("dispatch")
114
+ if not result.allowed:
115
+ raise PermissionError(result.reason)
116
+ result = rbac.check_rate_limit("dispatch")
117
+ if not result.allowed:
118
+ raise RateLimitError(result.reason)
119
+ rbac.record_dispatch()
120
+ """
121
+
122
+ def __init__(
123
+ self,
124
+ role: Role = Role.OPERATOR,
125
+ config: RateLimitConfig | None = None,
126
+ ) -> None:
127
+ self._role = role
128
+ self._config = config or RateLimitConfig()
129
+ self._limits = RateLimitState()
130
+ self._permissions = _ROLE_PERMISSIONS[role]
131
+
132
+ @property
133
+ def role(self) -> Role:
134
+ return self._role
135
+
136
+ @role.setter
137
+ def role(self, value: Role) -> None:
138
+ self._role = value
139
+ self._permissions = _ROLE_PERMISSIONS[value]
140
+
141
+ # ── Permission checks (§6G.1) ─────────────────────────────
142
+
143
+ def check_permission(self, permission: str) -> AccessResult:
144
+ """Check if current role has the given permission."""
145
+ if permission in self._permissions:
146
+ return AccessResult(allowed=True, role=self._role)
147
+ return AccessResult(
148
+ allowed=False,
149
+ reason=f"Role {self._role.name} lacks permission '{permission}'",
150
+ role=self._role,
151
+ )
152
+
153
+ def has_permission(self, permission: str) -> bool:
154
+ return permission in self._permissions
155
+
156
+ # ── Rate limiting (§6G.2) ─────────────────────────────────
157
+
158
+ def check_rate_limit(self, operation: str, payload_bytes: int = 0) -> AccessResult:
159
+ """Check if the operation is within rate limits."""
160
+ now = time.time()
161
+ window_start = now - 60.0 # 1-minute sliding window
162
+
163
+ if operation == Permission.DISPATCH:
164
+ # Clean old entries
165
+ while self._limits.dispatch_timestamps and self._limits.dispatch_timestamps[0] < window_start:
166
+ self._limits.dispatch_timestamps.popleft()
167
+
168
+ if len(self._limits.dispatch_timestamps) >= self._config.dispatch_per_minute:
169
+ return AccessResult(
170
+ allowed=False,
171
+ reason=f"Dispatch rate limit exceeded: {self._config.dispatch_per_minute}/min",
172
+ role=self._role,
173
+ )
174
+
175
+ elif operation == Permission.INGEST:
176
+ # Clean old entries
177
+ while self._limits.ingest_bytes and self._limits.ingest_bytes[0][0] < window_start:
178
+ self._limits.ingest_bytes.popleft()
179
+
180
+ total_mb = sum(b for _, b in self._limits.ingest_bytes) / (1024 * 1024)
181
+ incoming_mb = payload_bytes / (1024 * 1024)
182
+ if total_mb + incoming_mb > self._config.ingest_mb_per_minute:
183
+ return AccessResult(
184
+ allowed=False,
185
+ reason=f"Ingest rate limit exceeded: {self._config.ingest_mb_per_minute} MB/min",
186
+ role=self._role,
187
+ )
188
+
189
+ # Session token cap
190
+ if self._config.session_token_cap > 0:
191
+ if self._limits.session_tokens_used >= self._config.session_token_cap:
192
+ return AccessResult(
193
+ allowed=False,
194
+ reason=f"Session token cap reached: {self._config.session_token_cap}",
195
+ role=self._role,
196
+ )
197
+
198
+ return AccessResult(allowed=True, role=self._role)
199
+
200
+ # ── Recording operations ──────────────────────────────────
201
+
202
+ def record_dispatch(self, tokens_used: int = 0) -> None:
203
+ """Record a dispatch operation for rate limiting."""
204
+ self._limits.dispatch_timestamps.append(time.time())
205
+ self._limits.session_tokens_used += tokens_used
206
+
207
+ def record_ingest(self, payload_bytes: int) -> None:
208
+ """Record an ingest operation for rate limiting."""
209
+ self._limits.ingest_bytes.append((time.time(), payload_bytes))
210
+
211
+ def record_tokens(self, count: int) -> None:
212
+ """Record token consumption."""
213
+ self._limits.session_tokens_used += count
214
+
215
+ @property
216
+ def session_tokens_used(self) -> int:
217
+ return self._limits.session_tokens_used
218
+
219
+ def reset_limits(self) -> None:
220
+ """Reset all rate limit counters."""
221
+ self._limits = RateLimitState()
@@ -0,0 +1,164 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Input validation — Layer 1 structural validation, CANNOT be disabled (§7.4).
4
+
5
+ Enforces:
6
+ - Size limit: 50 MB
7
+ - Unicode NFC normalization
8
+ - Null byte stripping
9
+ - Control character stripping (preserves \\n, \\t, \\r)
10
+ - MIME type validation
11
+ - Metadata key count ≤ 50
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import unicodedata
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+
22
+ @dataclass
23
+ class ValidationResult:
24
+ """Result of input validation."""
25
+
26
+ valid: bool
27
+ sanitized_text: str
28
+ warnings: list[str] = field(default_factory=list)
29
+ original_size: int = 0
30
+ sanitized_size: int = 0
31
+ null_bytes_removed: int = 0
32
+ control_chars_removed: int = 0
33
+ metadata_keys_truncated: int = 0
34
+
35
+
36
+ # Allowed MIME types for fact ingestion
37
+ ALLOWED_MIME_TYPES = frozenset({
38
+ "text/plain",
39
+ "text/markdown",
40
+ "text/html",
41
+ "text/csv",
42
+ "application/json",
43
+ "application/xml",
44
+ "text/xml",
45
+ "application/yaml",
46
+ "text/yaml",
47
+ })
48
+
49
+ # Size limits
50
+ MAX_INPUT_SIZE = 50 * 1024 * 1024 # 50 MB
51
+ MAX_METADATA_KEYS = 50
52
+
53
+ # Control character regex: match all control chars except \n \t \r
54
+ _CONTROL_CHAR_RE = re.compile(
55
+ r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]"
56
+ )
57
+
58
+
59
+ class InputValidator:
60
+ """Structural input validation — Layer 1, cannot be disabled (§7.4).
61
+
62
+ This validator ALWAYS runs on all input. It cannot be turned off.
63
+ It performs structural sanitization without modifying semantic content.
64
+
65
+ Usage:
66
+ validator = InputValidator()
67
+ result = validator.validate("input text")
68
+ if result.valid:
69
+ use(result.sanitized_text)
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ max_size: int = MAX_INPUT_SIZE,
75
+ max_metadata_keys: int = MAX_METADATA_KEYS,
76
+ ) -> None:
77
+ self._max_size = max_size
78
+ self._max_metadata_keys = max_metadata_keys
79
+
80
+ def validate(
81
+ self,
82
+ text: str,
83
+ mime_type: str | None = None,
84
+ metadata: dict[str, Any] | None = None,
85
+ ) -> ValidationResult:
86
+ """Validate and sanitize input text (§7.4).
87
+
88
+ Steps:
89
+ 1. Size check (50 MB limit)
90
+ 2. Unicode NFC normalization
91
+ 3. Null byte stripping
92
+ 4. Control character stripping (keep \\n, \\t, \\r)
93
+ 5. MIME type validation (if provided)
94
+ 6. Metadata key count check (≤ 50)
95
+ """
96
+ warnings: list[str] = []
97
+ original_size = len(text.encode("utf-8"))
98
+
99
+ # 1. Size limit (§6D.2)
100
+ if original_size > self._max_size:
101
+ return ValidationResult(
102
+ valid=False,
103
+ sanitized_text="",
104
+ warnings=[f"Input exceeds size limit: {original_size} > {self._max_size} bytes"],
105
+ original_size=original_size,
106
+ )
107
+
108
+ # 2. Unicode NFC normalization (§6D.2)
109
+ sanitized = unicodedata.normalize("NFC", text)
110
+
111
+ # 3. Null byte stripping (§6D.2)
112
+ null_count = sanitized.count("\x00")
113
+ if null_count > 0:
114
+ sanitized = sanitized.replace("\x00", "")
115
+ warnings.append(f"Stripped {null_count} null bytes")
116
+
117
+ # 4. Control character stripping — keep \n \t \r (§6D.3)
118
+ control_matches = _CONTROL_CHAR_RE.findall(sanitized)
119
+ control_count = len(control_matches)
120
+ if control_count > 0:
121
+ sanitized = _CONTROL_CHAR_RE.sub("", sanitized)
122
+ warnings.append(f"Stripped {control_count} control characters")
123
+
124
+ # 5. MIME type validation (§6D.4)
125
+ if mime_type is not None and mime_type not in ALLOWED_MIME_TYPES:
126
+ warnings.append(f"Unrecognized MIME type: {mime_type}")
127
+
128
+ # 6. Metadata key count (§6D.4)
129
+ metadata_truncated = 0
130
+ if metadata is not None and len(metadata) > self._max_metadata_keys:
131
+ metadata_truncated = len(metadata) - self._max_metadata_keys
132
+ warnings.append(
133
+ f"Metadata has {len(metadata)} keys, max {self._max_metadata_keys}; "
134
+ f"excess keys will be ignored"
135
+ )
136
+
137
+ sanitized_size = len(sanitized.encode("utf-8"))
138
+
139
+ return ValidationResult(
140
+ valid=True,
141
+ sanitized_text=sanitized,
142
+ warnings=warnings,
143
+ original_size=original_size,
144
+ sanitized_size=sanitized_size,
145
+ null_bytes_removed=null_count,
146
+ control_chars_removed=control_count,
147
+ metadata_keys_truncated=metadata_truncated,
148
+ )
149
+
150
+ def validate_metadata(self, metadata: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
151
+ """Validate and truncate metadata keys (§6D.4).
152
+
153
+ Returns (sanitized_metadata, warnings).
154
+ """
155
+ warnings: list[str] = []
156
+ if len(metadata) <= self._max_metadata_keys:
157
+ return dict(metadata), warnings
158
+
159
+ warnings.append(
160
+ f"Metadata truncated: {len(metadata)} → {self._max_metadata_keys} keys"
161
+ )
162
+ # Keep first N keys (deterministic order)
163
+ truncated = dict(list(metadata.items())[:self._max_metadata_keys])
164
+ return truncated, warnings
crp/state/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """State management — facts, warm store, event log, snapshots, cold storage."""
4
+
5
+ from .cold_storage import PersistedStateHeader, persist_to_cold, restore_from_cold
6
+ from .compaction import CompactionConfig, CompactionResult, compact, should_compact # noqa: F401
7
+ from .critical_state import CriticalState, StructuralState
8
+ from .event_log import FactEventLog
9
+ from .fact import StateFact, set_embedding_function
10
+ from .serialization import FactGraphSerializer
11
+ from .snapshot import EventLogSnapshot, SnapshotManager
12
+ from .warm_store import WarmStateStore, WarmStoreConfig
13
+
14
+ __all__ = [
15
+ "CriticalState",
16
+ "CompactionConfig",
17
+ "CompactionResult",
18
+ "compact",
19
+ "EventLogSnapshot",
20
+ "FactEventLog",
21
+ "FactGraphSerializer",
22
+ "SnapshotManager",
23
+ "StateFact",
24
+ "StructuralState",
25
+ "WarmStateStore",
26
+ "WarmStoreConfig",
27
+ "PersistedStateHeader",
28
+ "persist_to_cold",
29
+ "restore_from_cold",
30
+ "set_embedding_function",
31
+ ]