ct-toolkit 0.3.7__tar.gz → 0.3.8__tar.gz

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 (59) hide show
  1. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/PKG-INFO +1 -1
  2. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/__init__.py +1 -1
  3. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/exceptions.py +16 -0
  4. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/wrapper.py +134 -88
  5. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/l3_icm.py +34 -0
  6. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/endorsement/reflective.py +306 -15
  7. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/PKG-INFO +1 -1
  8. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/pyproject.toml +1 -1
  9. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/LICENSE +0 -0
  10. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/README.md +0 -0
  11. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/__main__.py +0 -0
  12. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/cli.py +0 -0
  13. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/__init__.py +0 -0
  14. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/compatibility.py +0 -0
  15. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/compression_guard.py +0 -0
  16. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/integrity.py +0 -0
  17. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/core/kernel.py +0 -0
  18. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/__init__.py +0 -0
  19. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/analysis.py +0 -0
  20. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/engine.py +0 -0
  21. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/l2_judge.py +0 -0
  22. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/loss.py +0 -0
  23. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/divergence/scheduler.py +0 -0
  24. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/endorsement/__init__.py +0 -0
  25. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/endorsement/probes/__init__.py +0 -0
  26. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/__init__.py +0 -0
  27. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/embedding.py +0 -0
  28. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/__init__.py +0 -0
  29. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/defense.yaml +0 -0
  30. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/finance.yaml +0 -0
  31. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/general.yaml +0 -0
  32. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/identity/templates/medical.yaml +0 -0
  33. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/kernels/__init__.py +0 -0
  34. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/kernels/default.yaml +0 -0
  35. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/kernels/defense.yaml +0 -0
  36. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/__init__.py +0 -0
  37. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/autogen.py +0 -0
  38. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/crewai.py +0 -0
  39. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/deepagents.py +0 -0
  40. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/langchain.py +0 -0
  41. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/middleware/litellm.py +0 -0
  42. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/provenance/__init__.py +0 -0
  43. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/provenance/log.py +0 -0
  44. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/provenance/vault/__init__.py +0 -0
  45. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/server.py +0 -0
  46. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/utils/__init__.py +0 -0
  47. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit/utils/logger.py +0 -0
  48. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/SOURCES.txt +0 -0
  49. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/dependency_links.txt +0 -0
  50. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/entry_points.txt +0 -0
  51. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/requires.txt +0 -0
  52. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/ct_toolkit.egg-info/top_level.txt +0 -0
  53. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/setup.cfg +0 -0
  54. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_analysis.py +0 -0
  55. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_cli.py +0 -0
  56. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_cli_extended.py +0 -0
  57. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_deep_agents_integration.py +0 -0
  58. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_loss.py +0 -0
  59. {ct_toolkit-0.3.7 → ct_toolkit-0.3.8}/tests/test_summarization_drift.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ct-toolkit
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems
5
5
  Author: Hakan Damar
6
6
  License: Apache-2.0
@@ -23,7 +23,7 @@ from ct_toolkit.core.exceptions import (
23
23
  ChainIntegrityError,
24
24
  )
25
25
 
26
- __version__ = "0.3.7"
26
+ __version__ = "0.3.8"
27
27
 
28
28
  __all__ = [
29
29
  "TheseusWrapper",
@@ -85,6 +85,22 @@ class CriticalDivergenceError(DivergenceError):
85
85
  )
86
86
 
87
87
 
88
+ class CriticalSandboxDivergenceError(DivergenceError):
89
+ """
90
+ Raised when a sandbox (staged) agent exhibits critical divergence during cooldown.
91
+ The staged kernel update is rejected and the sandbox is torn down.
92
+ """
93
+ def __init__(self, endorsement_id: str, l1_score: float | None, reason: str):
94
+ self.endorsement_id = endorsement_id
95
+ self.l1_score = l1_score
96
+ self.reason = reason
97
+ super().__init__(
98
+ f"Sandbox agent for staged endorsement '{endorsement_id[:8]}...' exhibited "
99
+ f"critical divergence during cooldown (score={l1_score}, reason={reason!r}). "
100
+ "Staged update rejected and sandbox torn down."
101
+ )
102
+
103
+
88
104
  # ── Provenance Errors ────────────────────────────────────────────────────────
89
105
 
90
106
  class ProvenanceError(CTToolkitError):
@@ -16,23 +16,33 @@ In the background:
16
16
  2. Each request/response is written to the Provenance Log with HMAC signature.
17
17
  3. Embedding cosine similarity (L1 ECS) is calculated.
18
18
  4. If the threshold is exceeded, L2/L3 is triggered.
19
+ 5. Staged Approval (Cooldown): Risky kernel updates are monitored via shadow requests
20
+ before being promoted to production.
19
21
  """
20
22
  from __future__ import annotations
21
23
 
22
24
  import time
23
- import hashlib
24
25
  import os
25
26
  from dataclasses import dataclass, field
26
27
  from pathlib import Path
27
- from typing import Any, Iterator, Optional, Callable, Dict
28
+ from typing import Any, Optional, Callable, Dict, List
29
+
28
30
  import litellm
29
31
 
30
32
  from ct_toolkit.core.kernel import ConstitutionalKernel
31
33
  from ct_toolkit.core.compatibility import CompatibilityLayer, CompatibilityResult
32
- from ct_toolkit.core.exceptions import CTToolkitError, MissingClientError
34
+ from ct_toolkit.core.exceptions import (
35
+ MissingClientError,
36
+ CriticalSandboxDivergenceError
37
+ )
33
38
  from ct_toolkit.core.integrity import IntegrityMonitor
34
39
  from ct_toolkit.divergence.scheduler import RiskProfile
35
40
  from ct_toolkit.core.compression_guard import ContextCompressionGuard
41
+ from ct_toolkit.endorsement.reflective import (
42
+ ReflectiveEndorsement,
43
+ StagedUpdateManager,
44
+ CooldownCalculator
45
+ )
36
46
  from ct_toolkit.utils.logger import get_logger
37
47
 
38
48
  logger = get_logger(__name__)
@@ -74,6 +84,11 @@ class WrapperConfig:
74
84
  compression_threshold: float = 0.85
75
85
  compression_passive_detection: bool = True
76
86
 
87
+ # -- Staged Approval (Cooldown) --
88
+ endorsement_cooldown_base: int = CooldownCalculator.DEFAULT_BASE_SECONDS
89
+ endorsement_cooldown_max: int = CooldownCalculator.DEFAULT_MAX_SECONDS
90
+ endorsement_no_probe_penalty: int = CooldownCalculator.DEFAULT_NO_PROBE_PENALTY_S
91
+
77
92
 
78
93
  @dataclass
79
94
  class CTResponse:
@@ -85,6 +100,8 @@ class CTResponse:
85
100
  divergence_tier: str | None = None # "ok" | "l1_warning" | "l2_judge" | "l3_icm"
86
101
  provenance_id: str | None = None
87
102
  raw_response: Any = field(default=None, repr=False)
103
+ # Sandbox metadata if staged updates are active
104
+ sandbox_divergence: float | None = None
88
105
 
89
106
  def __str__(self) -> str:
90
107
  return self.content
@@ -125,15 +142,6 @@ class TheseusWrapper:
125
142
  ) -> None:
126
143
  """
127
144
  Initializes TheseusWrapper.
128
-
129
- Args:
130
- client: An existing API client instance (OpenAI, Anthropic, etc.).
131
- Optional if 'provider' is specified.
132
- config: Optional WrapperConfig instance.
133
- provider: Provider name (e.g. "openai", "anthropic", "ollama", "google", "cohere").
134
- If provided, LiteLLM will be used to initialize the client internally.
135
- project_root: Path to the user's project root directory. Used to find a custom 'config/' folder.
136
- Defaults to the current working directory.
137
145
  """
138
146
  self._config = config or WrapperConfig(
139
147
  kernel_path=kernel_path,
@@ -175,6 +183,9 @@ class TheseusWrapper:
175
183
  )
176
184
  self._shadow_history: Optional[List[Dict[str, str]]] = None
177
185
 
186
+ # ── Staged Approval (Cooldown) ──
187
+ self._staged_manager = StagedUpdateManager()
188
+
178
189
  # ── Integrity Monitoring ──
179
190
  self._integrity_monitor = IntegrityMonitor()
180
191
  self._register_monitored_files()
@@ -183,8 +194,7 @@ class TheseusWrapper:
183
194
 
184
195
  def _register_monitored_files(self):
185
196
  """
186
- Finds and registers all critical configuration files for integrity monitoring,
187
- including both built-in package files and user-provided custom configs.
197
+ Finds and registers all critical configuration files for integrity monitoring.
188
198
  """
189
199
  logger.debug("Registering configuration files for integrity monitoring.")
190
200
 
@@ -205,10 +215,7 @@ class TheseusWrapper:
205
215
  self._integrity_monitor.register_file(file_path)
206
216
 
207
217
  except Exception:
208
- # Fallback for older Python or different environments
209
218
  package_root = Path(__file__).parent.parent
210
- # This is less ideal as it might find non-package files, but it's a
211
- # reasonable fallback for development environments.
212
219
  for pattern in internal_patterns:
213
220
  for file_path in package_root.rglob(pattern):
214
221
  if file_path.is_file():
@@ -229,23 +236,16 @@ class TheseusWrapper:
229
236
  # ── Factory / Init ─────────────────────────────────────────────────────────
230
237
 
231
238
  def _detect_provider(self, client: Any) -> str:
232
- """
233
- Detects the provider from the client instance or string.
234
- Returns 'unknown' if detection fails.
235
- """
236
239
  if client is None:
237
240
  return "unknown"
238
241
 
239
242
  if isinstance(client, str):
240
- # client passed as a string (e.g. "openai")
241
243
  return client.lower()
242
244
 
243
245
  try:
244
- # Check for LiteLLM specific attributes if any
245
246
  if hasattr(client, "provider") and isinstance(client.provider, str):
246
247
  return client.provider
247
248
 
248
- # Check module name
249
249
  module = type(client).__module__.lower()
250
250
  if "openai" in module: return "openai"
251
251
  if "anthropic" in module: return "anthropic"
@@ -258,21 +258,18 @@ class TheseusWrapper:
258
258
  return "unknown"
259
259
 
260
260
  def _load_kernel(self) -> ConstitutionalKernel:
261
- # 1. Check for explicit path
262
261
  if self._config.kernel_path:
263
262
  logger.debug(f"Loading kernel from explicit path: {self._config.kernel_path}")
264
263
  return ConstitutionalKernel.from_yaml(self._config.kernel_path)
265
264
 
266
265
  safe_name = os.path.basename(self._config.kernel_name)
267
266
 
268
- # 2. Check for user-defined config directory
269
267
  if self._project_root:
270
268
  user_config_path = self._project_root / "config" / f"{safe_name}.yaml"
271
269
  if user_config_path.exists():
272
270
  logger.debug(f"Loading kernel from user config: {user_config_path}")
273
271
  return ConstitutionalKernel.from_yaml(user_config_path)
274
272
 
275
- # 3. Load built-in kernel using importlib.resources
276
273
  try:
277
274
  from importlib.resources import files
278
275
  kernel_resource = files("ct_toolkit.kernels").joinpath(f"{safe_name}.yaml")
@@ -280,7 +277,6 @@ class TheseusWrapper:
280
277
  logger.debug(f"Loading built-in kernel: {safe_name}.yaml")
281
278
  return ConstitutionalKernel.from_yaml(kernel_resource)
282
279
  except (ImportError, FileNotFoundError):
283
- # Fallback for older python versions
284
280
  kernel_path = (
285
281
  Path(__file__).parent.parent
286
282
  / "kernels"
@@ -296,7 +292,6 @@ class TheseusWrapper:
296
292
  return ConstitutionalKernel.default()
297
293
 
298
294
  def _init_provenance_log(self) -> Any:
299
- """Initializes the Provenance Log vault adapter (Will be fully implemented in Step 2)."""
300
295
  from ct_toolkit.provenance.log import ProvenanceLog
301
296
  return ProvenanceLog(
302
297
  vault_type=self._config.vault_type,
@@ -304,13 +299,10 @@ class TheseusWrapper:
304
299
  )
305
300
 
306
301
  def _init_identity_layer(self) -> Any:
307
- """Initializes the Identity Embedding Layer."""
308
302
  from ct_toolkit.identity.embedding import IdentityEmbeddingLayer
309
303
 
310
- # Guard against using raw clients that don't support embeddings
311
304
  emb_client = self._config.embedding_client
312
305
  if emb_client is None:
313
- # Only default to main client if it's known to be OpenAI (standard for embeddings)
314
306
  if self._provider == "openai":
315
307
  emb_client = self._client
316
308
  else:
@@ -325,7 +317,6 @@ class TheseusWrapper:
325
317
  )
326
318
 
327
319
  def _init_divergence_engine(self) -> Any:
328
- """Initializes the Divergence Engine (judge_client is required for L2/L3)."""
329
320
  from ct_toolkit.divergence.engine import DivergenceEngine
330
321
  from ct_toolkit.divergence.scheduler import ElasticityScheduler
331
322
 
@@ -383,15 +374,10 @@ class TheseusWrapper:
383
374
  ) -> CTResponse:
384
375
  """
385
376
  Sends a single message, returning the response with identity protection.
386
-
387
- Args:
388
- message: User message
389
- model: Model to use (provider default is used)
390
- system: Additional system prompt (appended to kernel)
391
- history: Conversation history [{"role": ..., "content": ...}]
392
377
  """
393
378
  # --- Pre-flight Checks ---
394
379
  self._integrity_monitor.verify_integrity()
380
+ self._process_staged_updates()
395
381
 
396
382
  composed_system = self._compose_system_prompt(system)
397
383
  messages = self._build_messages(message, history, composed_system)
@@ -449,6 +435,11 @@ class TheseusWrapper:
449
435
 
450
436
  break
451
437
 
438
+ # --- Staged Approval Monitoring (Shadow Requests) ---
439
+ sandbox_divergence = None
440
+ if self._staged_manager.has_active_staged():
441
+ sandbox_divergence = self._run_shadow_requests(message, history, model, **kwargs)
442
+
452
443
  # Provenance Log record
453
444
  provenance_id = None
454
445
  if self._config.log_requests:
@@ -464,6 +455,7 @@ class TheseusWrapper:
464
455
  "template": self._config.template,
465
456
  "kernel": self._kernel.name,
466
457
  "drift_alert_enabled": self._config.drift_alert_callback is not None,
458
+ "sandbox_divergence": sandbox_divergence,
467
459
  },
468
460
  )
469
461
 
@@ -475,8 +467,99 @@ class TheseusWrapper:
475
467
  divergence_tier=div_result.tier.value,
476
468
  provenance_id=provenance_id,
477
469
  raw_response=raw_response,
470
+ sandbox_divergence=sandbox_divergence,
478
471
  )
479
472
 
473
+ # ── Shadow Request Logic ──────────────────────────────────────────────────
474
+
475
+ def _run_shadow_requests(
476
+ self,
477
+ message: str,
478
+ history: list[dict[str, str]] | None,
479
+ model: str | None,
480
+ **kwargs: Any,
481
+ ) -> float | None:
482
+ """
483
+ Runs the interaction against one or more 'staged' (sandbox) kernels
484
+ to observe if the proposed changes cause identity drift.
485
+ """
486
+ active_staged = self._staged_manager.get_active()
487
+ if not active_staged:
488
+ return None
489
+
490
+ # To keep it simple, we monitor the first active staged update
491
+ staged_record = active_staged[0]
492
+
493
+ # 1. Clone current kernel and apply the staged rule
494
+ sandbox_kernel = ConstitutionalKernel.from_dict(self._kernel.to_dict())
495
+ try:
496
+ sandbox_kernel.update_commitment(
497
+ staged_record.commitment_id,
498
+ staged_record.rule_text
499
+ )
500
+ except Exception as e:
501
+ logger.error(f"Failed to apply staged rule to sandbox kernel: {e}")
502
+ return None
503
+
504
+ # 2. Call provider with sandbox kernel
505
+ sandbox_system = sandbox_kernel.get_system_prompt_injection()
506
+ sandbox_messages = self._build_messages(message, history, sandbox_system)
507
+
508
+ try:
509
+ # We use the same model/client as the live request
510
+ sandbox_response = self._call_provider(sandbox_messages, model=model, **kwargs)
511
+ sandbox_content = self._extract_content(sandbox_response)
512
+
513
+ # 3. Analyze sandbox divergence
514
+ sandbox_div_score = self._identity_layer.compute_divergence(sandbox_content)
515
+
516
+ # 4. Critical Divergence Check
517
+ if sandbox_div_score >= self._config.divergence_l3_threshold:
518
+ reason = f"Sandbox divergence L1 score {sandbox_div_score:.4f} exceeded L3 threshold."
519
+ self._staged_manager.reject_staged(staged_record.id, reason=reason)
520
+ raise CriticalSandboxDivergenceError(
521
+ endorsement_id=staged_record.id,
522
+ l1_score=sandbox_div_score,
523
+ reason=reason
524
+ )
525
+
526
+ return sandbox_div_score
527
+
528
+ except CriticalSandboxDivergenceError:
529
+ raise
530
+ except Exception as e:
531
+ logger.warning(f"Shadow request for staged update failed: {e}")
532
+ return None
533
+
534
+ def _process_staged_updates(self) -> None:
535
+ """
536
+ Checks for staged updates whose cooldown has expired and
537
+ promotes them to the production kernel.
538
+ """
539
+ promotable = self._staged_manager.get_promotable()
540
+ for record in promotable:
541
+ try:
542
+ logger.info(
543
+ f"Promoting staged endorsement to production: {record.id[:8]}... "
544
+ f"rule='{record.rule_text}'"
545
+ )
546
+ self._kernel.update_commitment(record.commitment_id, record.rule_text)
547
+
548
+ # Write promotion event to log
549
+ if self._config.log_requests:
550
+ self._provenance_log.record(
551
+ request_text=f"[COOLDOWN_EXPIRED] Promoting staged rule",
552
+ response_text=f"Rule applied to production kernel: {record.rule_text}",
553
+ metadata={
554
+ "event_type": "staged_promotion",
555
+ "endorsement_id": record.id,
556
+ "kernel": self._kernel.name,
557
+ "commitment_id": record.commitment_id,
558
+ }
559
+ )
560
+ except Exception as e:
561
+ logger.error(f"Failed to promote staged update {record.id[:8]}: {e}")
562
+
480
563
  # ── Provider Dispatch ──────────────────────────────────────────────────────
481
564
 
482
565
  def _has_env_credentials(self) -> bool:
@@ -487,7 +570,6 @@ class TheseusWrapper:
487
570
  if self._provider == "anthropic":
488
571
  return bool(os.environ.get("ANTHROPIC_API_KEY"))
489
572
  if self._provider == "ollama":
490
- # Ollama typically doesn't need a key locally
491
573
  return True
492
574
  if self._provider == "google":
493
575
  return bool(os.environ.get("GOOGLE_API_KEY"))
@@ -500,44 +582,32 @@ class TheseusWrapper:
500
582
  **kwargs: Any,
501
583
  ) -> Any:
502
584
  """Uses LiteLLM for unified provider calling."""
503
- # --- Passive Context Compression Detection ---
504
585
  if self._config.compression_passive_detection and self._shadow_history:
505
- # Simple heuristic: message count dropped significantly
506
- # We use 0.7 as a threshold (30% drop)
507
586
  if len(messages) < len(self._shadow_history) * 0.7:
508
587
  self._compression_guard.on_passive_detection(
509
588
  original=self._shadow_history,
510
589
  compressed=messages
511
590
  )
512
591
 
513
- # Update shadow history for next call
514
592
  self._shadow_history = list(messages)
515
593
 
516
- # LiteLLM handles different message formats internally
517
-
518
- # Set default models per provider if not specified
519
594
  if not model:
520
595
  if self._provider == "openai": model = "gpt-4o-mini"
521
596
  elif self._provider == "anthropic": model = "claude-3-5-sonnet-latest"
522
597
  elif self._provider == "ollama": model = "llama3"
523
598
  else: model = "gpt-4o-mini"
524
599
 
525
- # LiteLLM expects "provider/model" format for non-default providers
526
- # Usually OpenAI models don't need a prefix.
527
600
  full_model = model
528
601
  if ":" in model:
529
- # If it has the legacy format "provider:model", convert it
530
602
  full_model = model.replace(":", "/", 1)
531
603
  elif self._provider not in ("openai", "unknown"):
532
604
  full_model = f"{self._provider}/{model}"
533
605
 
534
- # Extract connection info from client if possible
535
606
  if hasattr(self._client, "base_url") and self._client.base_url:
536
607
  kwargs["api_base"] = str(self._client.base_url)
537
608
  if hasattr(self._client, "api_key") and self._client.api_key:
538
609
  kwargs["api_key"] = self._client.api_key
539
610
 
540
- # Transfer common client configurations if available
541
611
  if hasattr(self._client, "timeout") and "timeout" not in kwargs:
542
612
  kwargs["timeout"] = self._client.timeout
543
613
 
@@ -552,7 +622,6 @@ class TheseusWrapper:
552
622
  def _compose_system_prompt(self, extra: str | None) -> str:
553
623
  kernel_injection = self._kernel.get_system_prompt_injection()
554
624
 
555
- # If this kernel contains propagated constraints, add a specific header
556
625
  if any(a.id.startswith("propagated_") for a in self._kernel.anchors):
557
626
  kernel_injection = (
558
627
  "# Mother Agent Constraints\n"
@@ -568,13 +637,11 @@ class TheseusWrapper:
568
637
  def propagate_headers(self) -> dict[str, str]:
569
638
  """
570
639
  Returns a dictionary of headers to be used when calling sub-agents.
571
- Includes the current kernel serialized for propagation.
572
640
  """
573
641
  import json
574
642
  import base64
575
643
 
576
644
  kernel_data = self._kernel.to_dict()
577
- # Mark as read-only for sub-agents
578
645
  kernel_data["is_readonly"] = True
579
646
 
580
647
  encoded_kernel = base64.b64encode(json.dumps(kernel_data).encode()).decode()
@@ -598,31 +665,26 @@ class TheseusWrapper:
598
665
  return messages
599
666
 
600
667
  def _extract_content(self, raw: Any) -> str:
601
- # Handle dict responses
602
668
  if isinstance(raw, dict):
603
669
  if "choices" in raw and isinstance(raw["choices"], list) and raw["choices"]:
604
670
  return raw["choices"][0].get("message", {}).get("content", "") or ""
605
671
  if "message" in raw:
606
- # Ollama dictate format
607
672
  return raw["message"].get("content", "") or ""
608
673
  return str(raw)
609
674
 
610
- # LiteLLM ModelResponse (Standardized for all providers)
611
675
  try:
612
676
  if hasattr(raw, "choices") and raw.choices:
613
677
  choice = raw.choices[0]
614
678
  if hasattr(choice, "message") and choice.message:
615
679
  return choice.message.content or ""
616
- # Some older/alternative formats
617
680
  if hasattr(choice, "text"):
618
681
  return choice.text or ""
619
682
  except Exception:
620
683
  pass
621
684
 
622
- # Fallback raw extraction for edge cases
623
- if hasattr(raw, "content") and isinstance(raw.content, list): # Anthropic style
685
+ if hasattr(raw, "content") and isinstance(raw.content, list):
624
686
  return raw.content[0].text if raw.content else ""
625
- if hasattr(raw, "message"): # Ollama generic style
687
+ if hasattr(raw, "message"):
626
688
  return getattr(raw.message, "content", "") or ""
627
689
 
628
690
  return str(raw)
@@ -635,7 +697,6 @@ class TheseusWrapper:
635
697
  return fallback or "unknown"
636
698
 
637
699
  def _run_divergence_engine(self, message: str, response: str, interaction_count: int = 0, skip_l3: bool = False) -> Any:
638
- """Runs the staged divergence engine (L1 -> L2 -> L3)."""
639
700
  try:
640
701
  return self._divergence_engine.analyze(message, response, interaction_count, skip_l3=skip_l3)
641
702
  except Exception as e:
@@ -649,10 +710,6 @@ class TheseusWrapper:
649
710
  # ── Kernel Management ────────────────────────────────────────────────────────
650
711
 
651
712
  def validate_user_rule(self, rule_text: str) -> None:
652
- """
653
- Validates a user-defined rule against the kernel.
654
- Raises an exception if there is a conflict.
655
- """
656
713
  self._kernel.validate_user_rule(rule_text)
657
714
 
658
715
  def endorse_rule(
@@ -664,15 +721,7 @@ class TheseusWrapper:
664
721
  ) -> Any:
665
722
  """
666
723
  Validates the rule; initiates the Reflective Endorsement
667
- flow if there is a plastic conflict. Throws hard reject on axiomatic violation.
668
-
669
- Args:
670
- rule_text: The rule to be enforced
671
- operator_id: The identity of the person granting approval
672
- approval_channel: Custom approval channel (default: CLI)
673
- commitment_new_value: New value to assign to the commitment
674
-
675
- Returns: EndorsementRecord
724
+ flow if there is a plastic conflict.
676
725
  """
677
726
  from ct_toolkit.endorsement.reflective import ReflectiveEndorsement
678
727
 
@@ -680,6 +729,11 @@ class TheseusWrapper:
680
729
  kernel=self._kernel,
681
730
  provenance_log=self._provenance_log,
682
731
  approval_channel=approval_channel,
732
+ staged_manager=self._staged_manager,
733
+ cooldown_base_s=self._config.endorsement_cooldown_base,
734
+ cooldown_max_s=self._config.endorsement_cooldown_max,
735
+ no_probe_penalty_s=self._config.endorsement_no_probe_penalty,
736
+ template=self._config.template,
683
737
  )
684
738
  return re.validate_and_endorse(
685
739
  rule_text=rule_text,
@@ -688,18 +742,6 @@ class TheseusWrapper:
688
742
  )
689
743
 
690
744
  def export_provenance_log(self) -> list[dict[str, Any]]:
691
- """
692
- Exports the entire verified provenance log.
693
-
694
- Verifies the integrity of the log before returning the data.
695
- This is intended for external auditing and analysis.
696
-
697
- Returns:
698
- A list of all log entries as dictionaries.
699
-
700
- Raises:
701
- ChainIntegrityError: If the log's hash chain is broken.
702
- """
703
745
  return self._provenance_log.export_log()
704
746
 
705
747
  @property
@@ -714,6 +756,10 @@ class TheseusWrapper:
714
756
  def divergence_engine(self) -> Any:
715
757
  return self._divergence_engine
716
758
 
759
+ @property
760
+ def staged_manager(self) -> StagedUpdateManager:
761
+ return self._staged_manager
762
+
717
763
  def __repr__(self) -> str:
718
764
  return (
719
765
  f"TheseusWrapper("
@@ -230,6 +230,40 @@ class ICMRunner:
230
230
  "ollama": "llama3",
231
231
  }.get(provider, "gpt-4o-mini")
232
232
 
233
+ # ── Probe Availability Check ────────────────────────────────────────────
234
+
235
+ @staticmethod
236
+ def has_probes(
237
+ template: str,
238
+ kernel_name: str, # noqa: ARG004 (reserved for future kernel-specific probes)
239
+ project_root: Path | None = None,
240
+ ) -> bool:
241
+ """
242
+ Returns True if any probe files exist for the given template/kernel combination.
243
+ Does NOT run the probes — intended as a fast pre-flight check for cooldown
244
+ duration calculation in the Reflective Endorsement protocol.
245
+
246
+ Probe availability order (mirrors _load_probes):
247
+ 1. base_probes.json — always counts as available
248
+ 2. user config: <project_root>/config/<template>_probes.json
249
+ 3. built-in domain: endorsement/probes/domain_probes/<template>_probes.json
250
+ """
251
+ probes_dir = ICMRunner.PROBES_DIR
252
+
253
+ # Base probes are always present; if they exist, we consider probes available.
254
+ if (probes_dir / "base_probes.json").exists():
255
+ return True
256
+
257
+ # Check user-supplied domain probes
258
+ if project_root:
259
+ user_probe_path = project_root / "config" / f"{template}_probes.json"
260
+ if user_probe_path.exists():
261
+ return True
262
+
263
+ # Check built-in domain probes
264
+ domain_path = probes_dir / "domain_probes" / f"{template}_probes.json"
265
+ return domain_path.exists()
266
+
233
267
  # ── Main Runner ────────────────────────────────────────────────────────
234
268
 
235
269
  def run(self) -> ICMReport:
@@ -7,9 +7,14 @@ Flow:
7
7
  1. User-defined rule conflicts with kernel → PlasticConflictError is caught
8
8
  2. ConflictRecord is created — conflict details are documented
9
9
  3. User is directed to approval channel (CLI / callback / API)
10
- 4. If user approves → EndorsementRecord is signed, written to Provenance Log
11
- 5. Kernel is updated, system continues to operate
12
- 6. If user rejects → system remains unchanged, rejection record written to log
10
+ 4. Decision options:
11
+ a. APPROVED → EndorsementRecord signed, kernel updated immediately
12
+ b. STAGED → EndorsementRecord signed, kernel update deferred to cooldown period.
13
+ A sandbox agent (cloned kernel) observes shadow traffic.
14
+ After cooldown expires without violations → kernel updated.
15
+ If CriticalDivergenceError during cooldown → REJECTED.
16
+ c. REJECTED → System remains unchanged, rejection record written to log
17
+ 5. All decisions are written to the Provenance Log.
13
18
 
14
19
  Each override is additionally monitored in subsequent ICM tests (flagged=True).
15
20
  """
@@ -37,6 +42,7 @@ class EndorsementDecision(str, Enum):
37
42
  REJECTED = "rejected"
38
43
  PENDING = "pending"
39
44
  FAILED = "failed"
45
+ STAGED = "staged" # Approved conditionally — pending cooldown observation
40
46
 
41
47
 
42
48
  @dataclass
@@ -64,7 +70,7 @@ class ConflictRecord:
64
70
 
65
71
  @dataclass
66
72
  class EndorsementRecord:
67
- """Signed record of user approval/rejection decision."""
73
+ """Signed record of user approval/rejection/staged decision."""
68
74
  id: str
69
75
  timestamp: float
70
76
  conflict_id: str
@@ -74,6 +80,10 @@ class EndorsementRecord:
74
80
  operator_id: str # Who approved / rejected
75
81
  rationale: str # Why it was approved / rejected
76
82
  kernel_name: str
83
+ # Cooldown fields (only set for STAGED decisions)
84
+ cooldown_duration_s: int | None = None # Total cooldown window in seconds
85
+ cooldown_until: float | None = None # Epoch timestamp when cooldown ends
86
+ probe_available: bool = True # Whether probes were available for this kernel/template
77
87
  content_hash: str = field(default="", repr=False)
78
88
 
79
89
  def __post_init__(self) -> None:
@@ -91,12 +101,27 @@ class EndorsementRecord:
91
101
  "operator_id": self.operator_id,
92
102
  "rationale": self.rationale,
93
103
  "kernel_name": self.kernel_name,
104
+ "cooldown_duration_s": self.cooldown_duration_s,
105
+ "cooldown_until": self.cooldown_until,
106
+ "probe_available": self.probe_available,
94
107
  }, sort_keys=True)
95
108
  return hashlib.sha256(payload.encode()).hexdigest()
96
109
 
110
+ @property
111
+ def is_staged(self) -> bool:
112
+ return self.decision == EndorsementDecision.STAGED
113
+
114
+ @property
115
+ def is_cooldown_expired(self) -> bool:
116
+ """True if this is a STAGED record whose cooldown period has elapsed."""
117
+ if not self.is_staged or self.cooldown_until is None:
118
+ return False
119
+ deadline: float = self.cooldown_until # narrowed: not None
120
+ return time.time() >= deadline
121
+
97
122
  def to_provenance_metadata(self) -> dict[str, Any]:
98
123
  """Metadata to be written to Provenance Log."""
99
- return {
124
+ meta: dict[str, Any] = {
100
125
  "event_type": "reflective_endorsement",
101
126
  "endorsement_id": self.id,
102
127
  "conflict_id": self.conflict_id,
@@ -106,8 +131,188 @@ class EndorsementRecord:
106
131
  "commitment_id": self.commitment_id,
107
132
  "kernel_name": self.kernel_name,
108
133
  "content_hash": self.content_hash,
109
- "flagged_for_icm": self.decision == EndorsementDecision.APPROVED and self.conflict_id != "none",
134
+ "flagged_for_icm": self.decision in (
135
+ EndorsementDecision.APPROVED, EndorsementDecision.STAGED
136
+ ) and self.conflict_id != "none",
110
137
  }
138
+ if self.is_staged:
139
+ meta.update({
140
+ "cooldown_duration_s": self.cooldown_duration_s,
141
+ "cooldown_until": self.cooldown_until,
142
+ "probe_available": self.probe_available,
143
+ })
144
+ return meta
145
+
146
+
147
+ # ── Cooldown Duration Calculator ─────────────────────────────────────────────────
148
+
149
+ class CooldownCalculator:
150
+ """
151
+ Calculates the appropriate cooldown duration for a staged kernel update.
152
+
153
+ Duration formula:
154
+ duration = min(base + no_probe_penalty + traffic_adjustment, max_duration)
155
+
156
+ Where:
157
+ - base : Starting cooldown length (default: 5 min)
158
+ - no_probe_penalty: Added when no domain probes exist for this kernel/template
159
+ - traffic_adjustment: Added based on requests-per-minute observed in the
160
+ Provenance Log (busier system → longer observation)
161
+ - max_duration : Hard cap (default: 10 min)
162
+ """
163
+
164
+ DEFAULT_BASE_SECONDS = 300 # 5 minutes
165
+ DEFAULT_MAX_SECONDS = 600 # 10 minutes
166
+ DEFAULT_NO_PROBE_PENALTY_S = 120 # 2 minutes penalty for missing probes
167
+ TRAFFIC_BUCKET_SECONDS = 60 # Window for measuring request rate (1 min)
168
+ TRAFFIC_THRESHOLD_PER_MIN = 10 # Requests/min above which we add time
169
+ TRAFFIC_PENALTY_SECONDS = 60 # Extra second-tier added per threshold breach
170
+
171
+ def __init__(
172
+ self,
173
+ base_seconds: int = DEFAULT_BASE_SECONDS,
174
+ max_seconds: int = DEFAULT_MAX_SECONDS,
175
+ no_probe_penalty_s: int = DEFAULT_NO_PROBE_PENALTY_S,
176
+ ) -> None:
177
+ self._base = base_seconds
178
+ self._max = max_seconds
179
+ self._no_probe_penalty = no_probe_penalty_s
180
+
181
+ def calculate(
182
+ self,
183
+ probe_available: bool,
184
+ provenance_log: Any | None,
185
+ kernel_name: str,
186
+ template: str,
187
+ ) -> int:
188
+ """
189
+ Returns the cooldown duration in seconds.
190
+
191
+ Args:
192
+ probe_available: Whether ICM probes exist for this kernel/template.
193
+ provenance_log: ProvenanceLog instance (for traffic estimation).
194
+ kernel_name: Kernel name (used for traffic lookup).
195
+ template: Template name (used for traffic lookup).
196
+
197
+ Returns:
198
+ int: Cooldown duration in seconds.
199
+ """
200
+ duration = self._base
201
+ penalty_reasons: list[str] = []
202
+
203
+ # Penalty 1: No domain probes available
204
+ if not probe_available:
205
+ duration += self._no_probe_penalty
206
+ penalty_reasons.append(
207
+ f"No domain probes found (+{self._no_probe_penalty}s)"
208
+ )
209
+
210
+ # Penalty 2: Traffic volume adjustment
211
+ if provenance_log:
212
+ rpm = self._estimate_rpm(provenance_log, kernel_name, template)
213
+ if rpm >= self.TRAFFIC_THRESHOLD_PER_MIN:
214
+ # Each full multiple of the threshold adds one time bucket
215
+ multiplier = int(rpm / self.TRAFFIC_THRESHOLD_PER_MIN)
216
+ traffic_penalty = min(multiplier * self.TRAFFIC_PENALTY_SECONDS, 120)
217
+ duration += traffic_penalty
218
+ penalty_reasons.append(
219
+ f"High traffic ({rpm} req/min, +{traffic_penalty}s)"
220
+ )
221
+
222
+ final = min(duration, self._max)
223
+ if penalty_reasons:
224
+ logger.info(
225
+ f"Cooldown duration adjusted: base={self._base}s → {final}s "
226
+ f"[{', '.join(penalty_reasons)}]"
227
+ )
228
+ else:
229
+ logger.info(f"Cooldown duration: {final}s (base, no penalties)")
230
+
231
+ return final
232
+
233
+ def _estimate_rpm(
234
+ self,
235
+ provenance_log: Any,
236
+ kernel_name: str,
237
+ template: str,
238
+ ) -> float:
239
+ """Estimates recent requests-per-minute from the Provenance Log."""
240
+ try:
241
+ entries = provenance_log.get_entries(limit=200)
242
+ cutoff = time.time() - self.TRAFFIC_BUCKET_SECONDS
243
+ recent = [
244
+ e for e in entries
245
+ if e.timestamp >= cutoff
246
+ and e.metadata.get("kernel") == kernel_name
247
+ and e.metadata.get("template") == template
248
+ ]
249
+ return len(recent)
250
+ except Exception as e:
251
+ logger.debug(f"Could not estimate RPM from provenance log: {e}")
252
+ return 0.0
253
+
254
+
255
+ # ── Staged Update Manager ──────────────────────────────────────────────────────────
256
+
257
+ class StagedUpdateManager:
258
+ """
259
+ Tracks pending STAGED endorsements and promotes them to APPROVED
260
+ after the cooldown period elapses without critical violations.
261
+
262
+ Developer Usage:
263
+ # On each TheseusWrapper.chat() cycle, call:
264
+ manager.tick(provenance_log)
265
+ # If the production kernel needs updating:
266
+ promoted = manager.get_promotable()
267
+ for record in promoted:
268
+ kernel.update_commitment(record.commitment_id, record.rule_text)
269
+
270
+ Typically owned by TheseusWrapper and wired into the chat() loop.
271
+ """
272
+
273
+ def __init__(self) -> None:
274
+ self._staged: list[EndorsementRecord] = []
275
+
276
+ def register(self, record: EndorsementRecord) -> None:
277
+ """Registers a STAGED record for tracking."""
278
+ if record.is_staged:
279
+ self._staged.append(record)
280
+ logger.info(
281
+ f"StagedUpdateManager: registered staged endorsement "
282
+ f"id={record.id[:8]}... cooldown_until={record.cooldown_until}"
283
+ )
284
+
285
+ def get_active(self) -> list[EndorsementRecord]:
286
+ """Returns all staged endorsements still in their cooldown window."""
287
+ return [r for r in self._staged if not r.is_cooldown_expired]
288
+
289
+ def get_promotable(self) -> list[EndorsementRecord]:
290
+ """
291
+ Returns staged endorsements whose cooldown has expired without
292
+ being rejected. Removes them from the tracking list.
293
+ """
294
+ promotable = [r for r in self._staged if r.is_cooldown_expired]
295
+ for r in promotable:
296
+ self._staged.remove(r)
297
+ return promotable
298
+
299
+ def reject_staged(self, record_id: str, reason: str = "") -> bool:
300
+ """
301
+ Rejects and removes a staged endorsement (e.g. on CriticalDivergenceError).
302
+ Returns True if found and removed.
303
+ """
304
+ for r in self._staged:
305
+ if r.id == record_id:
306
+ self._staged.remove(r)
307
+ logger.warning(
308
+ f"StagedUpdateManager: staged endorsement REJECTED "
309
+ f"id={record_id[:8]}... reason={reason!r}"
310
+ )
311
+ return True
312
+ return False
313
+
314
+ def has_active_staged(self) -> bool:
315
+ return len(self.get_active()) > 0
111
316
 
112
317
 
113
318
  # ── Approval Channels ─────────────────────────────────────────────────────────────
@@ -123,7 +328,8 @@ def cli_approval_channel(conflict: ConflictRecord) -> tuple[EndorsementDecision,
123
328
  """
124
329
  Default CLI approval channel.
125
330
  Prompts user for approval in interactive terminal.
126
-
331
+ Includes a 'STAGED' option for supervised cooldown observation.
332
+
127
333
  WARNING: This blocks the current thread. In server environments (FastAPI/Flask),
128
334
  always use a custom non-blocking callback.
129
335
  """
@@ -136,24 +342,31 @@ def cli_approval_channel(conflict: ConflictRecord) -> tuple[EndorsementDecision,
136
342
  return EndorsementDecision.PENDING, "system", "Awaiting external approval (non-TTY)"
137
343
 
138
344
  print(conflict.summary())
139
- print("\nDo you want to override this rule conflict?")
140
- print(" [y] Yes — approve and sign override")
345
+ print("\nHow do you want to handle this rule conflict?")
346
+ print(" [y] Yes — approve and apply kernel update immediately")
347
+ print(" [s] Stage — approve with cooldown (sandbox observation period)")
141
348
  print(" [n] No — cancel, keep existing rule")
142
349
 
143
350
  while True:
144
351
  try:
145
- choice = input("\nYour decision (y/n): ").strip().lower()
352
+ choice = input("\nYour decision (y/s/n): ").strip().lower()
146
353
  if choice in ("y", "yes"):
147
354
  operator_id = input("Operator ID (name/email): ").strip() or "anonymous"
148
355
  rationale = input("Override rationale: ").strip() or "Manual approval"
149
356
  return EndorsementDecision.APPROVED, operator_id, rationale
357
+ elif choice in ("s", "stage", "staged"):
358
+ operator_id = input("Operator ID (name/email): ").strip() or "anonymous"
359
+ rationale = input("Staged approval rationale: ").strip() or "Staged manual approval"
360
+ return EndorsementDecision.STAGED, operator_id, rationale
150
361
  elif choice in ("n", "no"):
151
362
  return EndorsementDecision.REJECTED, "system", "User rejected"
152
363
  else:
153
- print("Invalid input. Type 'y' or 'n'.")
364
+ print("Invalid input. Type 'y', 's', or 'n'.")
154
365
  except EOFError:
155
366
  # Handle cases where input() is called but stdin is closed
156
367
  return EndorsementDecision.PENDING, "system", "Stdin closed while awaiting input"
368
+ # Unreachable — satisfies static analysers that don't track while-True exhaustion
369
+ return EndorsementDecision.PENDING, "system", "Unexpected exit from approval loop"
157
370
 
158
371
 
159
372
  def auto_approve_channel(
@@ -173,6 +386,24 @@ def auto_approve_channel(
173
386
  return _channel
174
387
 
175
388
 
389
+ def auto_staged_channel(
390
+ operator_id: str = "auto",
391
+ rationale: str = "Auto staged approval",
392
+ ) -> ApprovalCallback:
393
+ """
394
+ Auto staged approval channel for test and CI environments.
395
+ Returns STAGED decision, which will trigger cooldown logic.
396
+ Do not use in production.
397
+ """
398
+ def _channel(conflict: ConflictRecord) -> tuple[EndorsementDecision, str, str]:
399
+ logger.warning(
400
+ f"AUTO-STAGED active: Override of '{conflict.rule_text}' staged. "
401
+ "This channel should only be used in test environments."
402
+ )
403
+ return EndorsementDecision.STAGED, operator_id, rationale
404
+ return _channel
405
+
406
+
176
407
  def auto_reject_channel() -> ApprovalCallback:
177
408
  """Auto reject channel for test environments."""
178
409
  def _channel(conflict: ConflictRecord) -> tuple[EndorsementDecision, str, str]:
@@ -206,10 +437,22 @@ class ReflectiveEndorsement:
206
437
  kernel: Any, # ConstitutionalKernel
207
438
  provenance_log: Any, # ProvenanceLog
208
439
  approval_channel: ApprovalCallback | None = None,
440
+ staged_manager: StagedUpdateManager | None = None,
441
+ cooldown_base_s: int = CooldownCalculator.DEFAULT_BASE_SECONDS,
442
+ cooldown_max_s: int = CooldownCalculator.DEFAULT_MAX_SECONDS,
443
+ no_probe_penalty_s: int = CooldownCalculator.DEFAULT_NO_PROBE_PENALTY_S,
444
+ template: str = "general",
209
445
  ) -> None:
210
446
  self._kernel = kernel
211
447
  self._log = provenance_log
212
448
  self._approval_channel = approval_channel or cli_approval_channel
449
+ self._staged_manager = staged_manager or StagedUpdateManager()
450
+ self._cooldown_calc = CooldownCalculator(
451
+ base_seconds=cooldown_base_s,
452
+ max_seconds=cooldown_max_s,
453
+ no_probe_penalty_s=no_probe_penalty_s,
454
+ )
455
+ self._template = template
213
456
  self._pending_records: list[EndorsementRecord] = []
214
457
  self._pre_update_snapshot: dict[str, Any] | None = None
215
458
 
@@ -228,7 +471,7 @@ class ReflectiveEndorsement:
228
471
  - Plastic conflict → RE flow starts, user approval requested
229
472
  - No conflict → rule is applied directly, written to log
230
473
 
231
- Returns: EndorsementRecord (APPROVED or REJECTED)
474
+ Returns: EndorsementRecord (APPROVED, STAGED, or REJECTED)
232
475
  Raises: AxiomaticViolationError — on axiomatic violation
233
476
  """
234
477
  try:
@@ -258,7 +501,7 @@ class ReflectiveEndorsement:
258
501
  operator_id: str,
259
502
  commitment_new_value: Any,
260
503
  ) -> EndorsementRecord:
261
- """Three stages of RE flow: Conflict record → Approval → Application."""
504
+ """Four stages of RE flow: Conflict record → Approval → Application → Log."""
262
505
 
263
506
  # Stage 1: Create ConflictRecord
264
507
  conflict = ConflictRecord(
@@ -275,6 +518,10 @@ class ReflectiveEndorsement:
275
518
  decision, actual_operator_id, rationale = self._approval_channel(conflict)
276
519
 
277
520
  # Stage 3: Apply decision
521
+ probe_available = self._check_probe_availability()
522
+ cooldown_duration_s: int | None = None
523
+ cooldown_until: float | None = None
524
+
278
525
  if decision == EndorsementDecision.APPROVED:
279
526
  applied = self._apply_override(
280
527
  commitment_id=conflict.conflicting_commitment_id,
@@ -295,6 +542,24 @@ class ReflectiveEndorsement:
295
542
  f"commitment={conflict.conflicting_commitment_id} | "
296
543
  f"operator={actual_operator_id or operator_id}"
297
544
  )
545
+
546
+ elif decision == EndorsementDecision.STAGED:
547
+ # Calculate cooldown duration — applies penalties as needed
548
+ cooldown_duration_s = self._cooldown_calc.calculate(
549
+ probe_available=probe_available,
550
+ provenance_log=self._log,
551
+ kernel_name=self._kernel.name,
552
+ template=self._template,
553
+ )
554
+ cooldown_until = time.time() + cooldown_duration_s
555
+ logger.info(
556
+ f"Override STAGED | "
557
+ f"commitment={conflict.conflicting_commitment_id} | "
558
+ f"cooldown={cooldown_duration_s}s | "
559
+ f"probe_available={probe_available} | "
560
+ f"operator={actual_operator_id or operator_id}"
561
+ )
562
+
298
563
  else:
299
564
  logger.info(
300
565
  f"Override rejected | "
@@ -313,12 +578,19 @@ class ReflectiveEndorsement:
313
578
  operator_id=actual_operator_id or operator_id,
314
579
  rationale=rationale,
315
580
  kernel_name=self._kernel.name,
581
+ cooldown_duration_s=cooldown_duration_s,
582
+ cooldown_until=cooldown_until,
583
+ probe_available=probe_available,
316
584
  )
317
585
 
318
586
  # Write to log in either case
319
587
  self._write_to_log(record)
320
588
  self._pending_records.append(record)
321
589
 
590
+ # Register with staged manager if STAGED
591
+ if record.is_staged:
592
+ self._staged_manager.register(record)
593
+
322
594
  return record
323
595
 
324
596
  # ── Helper Methods ──────────────────────────────────────────────────────
@@ -343,6 +615,21 @@ class ReflectiveEndorsement:
343
615
  )
344
616
  return False
345
617
 
618
+ def _check_probe_availability(self) -> bool:
619
+ """
620
+ Checks whether ICM probes exist for the current kernel/template combination.
621
+ Returns True if probes are available, False otherwise.
622
+ """
623
+ try:
624
+ from ct_toolkit.divergence.l3_icm import ICMRunner
625
+ return ICMRunner.has_probes(
626
+ template=self._template,
627
+ kernel_name=self._kernel.name,
628
+ )
629
+ except Exception as e:
630
+ logger.debug(f"Probe availability check failed: {e}. Assuming unavailable.")
631
+ return False
632
+
346
633
  def _write_to_log(self, record: EndorsementRecord) -> None:
347
634
  """Writes the endorsement record to the Provenance Log."""
348
635
  try:
@@ -373,12 +660,16 @@ class ReflectiveEndorsement:
373
660
  kernel_name=self._kernel.name,
374
661
  )
375
662
 
376
- # ── Querying ─────────────────────────────────────────────────────────────
663
+ # ── Public Properties / Accessors ──────────────────────────────────────────
664
+
665
+ @property
666
+ def staged_manager(self) -> StagedUpdateManager:
667
+ return self._staged_manager
377
668
 
378
669
  def get_pending_records(self) -> list[EndorsementRecord]:
379
670
  """Overrides approved in this session requiring ICM monitoring."""
380
671
  return [r for r in self._pending_records
381
- if r.decision == EndorsementDecision.APPROVED
672
+ if r.decision in (EndorsementDecision.APPROVED, EndorsementDecision.STAGED)
382
673
  and r.conflict_id != "none"]
383
674
 
384
675
  def has_active_overrides(self) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ct-toolkit
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems
5
5
  Author: Hakan Damar
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ct-toolkit"
7
- version = "0.3.7"
7
+ version = "0.3.8"
8
8
  description = "Computational Theseus Toolkit — Identity Continuity Guardrails for Agentic Systems"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes
File without changes