skillpool 4.3.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 (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. skillpool-4.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,651 @@
1
+ """Audit Layer V4.0 — Immutable audit evidence with OpenTelemetry alignment.
2
+
3
+ Upgraded from V3.4 (17 fields) to V4.0 (34 fields) per V1.1 Section 8.12.
4
+ OpenTelemetry-compatible: trace_id, span_id, parent_span_id, event_id.
5
+ Hash chain integrity with append-only design.
6
+
7
+ Architecture constraint:
8
+ - Audit MUST NOT be bypassed
9
+ - Audit is append-only integrity-protected
10
+ - Audit unavailable = fail closed for mutations
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__ = [
16
+ "AuditLayer",
17
+ "AuditRecord",
18
+ "AuditUnavailableError",
19
+ "log_event",
20
+ ]
21
+
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import uuid
26
+ from dataclasses import dataclass, field
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from skillpool.config import get_data_dir
32
+ from skillpool.utils.time_utils import utc_now
33
+
34
+
35
+ def isoformat_z(dt) -> str:
36
+ """Return ISO 8601 format with Z suffix for UTC."""
37
+ if dt.tzinfo is None:
38
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
39
+ return dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
40
+
41
+
42
+ @dataclass
43
+ class AuditRecord:
44
+ """Immutable audit record — 34-field OTel-aligned schema (V4.0)."""
45
+
46
+ # ── Core identity (OTel-aligned) ──
47
+ audit_id: str
48
+ event_id: str = ""
49
+ trace_id: str = ""
50
+ span_id: str = ""
51
+ parent_span_id: str = ""
52
+
53
+ # ── Temporal ──
54
+ event_type: str = "skill_pool_event"
55
+ event_time: Any = field(default_factory=utc_now)
56
+ created_at: str = ""
57
+ updated_at: str = ""
58
+ duration_ms: float = 0.0
59
+
60
+ # ── Actor & session ──
61
+ actor: str = "system"
62
+ agent_id: str = "" # Required by audit_record.v4.json schema (SPIFFE SVID or agent identifier)
63
+ session_id: str = ""
64
+ tenant_id: str = "default"
65
+
66
+ # ── Source & target ──
67
+ source_component: str = "SkillPool"
68
+ action: str = ""
69
+ resource_type: str = "skill"
70
+ resource_id: str = ""
71
+
72
+ # ── Request routing ──
73
+ request_id: str = ""
74
+ correlation_id: str = ""
75
+
76
+ # ── Decision & result ──
77
+ policy_decision: str = "allow"
78
+ decision: str = ""
79
+ result: str = "success"
80
+ reason: str = ""
81
+ severity: str = "INFO"
82
+
83
+ # ── Integrity (hash chain) ──
84
+ previous_hash: str = ""
85
+ current_hash: str = ""
86
+ chain_index: int = 0
87
+ signature: str = ""
88
+
89
+ # ── Content hashes ──
90
+ input_hash: str = ""
91
+ output_hash: str = ""
92
+
93
+ # ── Metadata & compliance ──
94
+ metadata_json: str = "{}"
95
+ retention_class: str = "hot"
96
+ compliance_tags: str = ""
97
+
98
+ # ── Geo (optional) ──
99
+ geo_location: str = ""
100
+
101
+ # ── Trace provenance ──
102
+ trace_source: str = "" # "w3c_passthrough" | "skillpool_internal" | "test"
103
+
104
+ # ── Backward compat aliases ──
105
+ @property
106
+ def hash(self) -> str:
107
+ """Backward compat: hash → current_hash."""
108
+ return self.current_hash
109
+
110
+ @hash.setter
111
+ def hash(self, value: str) -> None:
112
+ self.current_hash = value
113
+
114
+ @property
115
+ def object_type(self) -> str:
116
+ """Backward compat: object_type → resource_type."""
117
+ return self.resource_type
118
+
119
+ @object_type.setter
120
+ def object_type(self, value: str) -> None:
121
+ self.resource_type = value
122
+
123
+ @property
124
+ def object_id(self) -> str:
125
+ """Backward compat: object_id → resource_id."""
126
+ return self.resource_id
127
+
128
+ @object_id.setter
129
+ def object_id(self, value: str) -> None:
130
+ self.resource_id = value
131
+
132
+ @property
133
+ def audit_ref(self) -> str:
134
+ """Backward compat: audit_ref → audit_id."""
135
+ return self.audit_id
136
+
137
+ @audit_ref.setter
138
+ def audit_ref(self, value: str) -> None:
139
+ self.audit_id = value
140
+
141
+
142
+ def log_event(
143
+ action: str,
144
+ actor: str = "system",
145
+ resource_type: str = "skill",
146
+ resource_id: str = "",
147
+ result: str = "success",
148
+ reason: str = "",
149
+ severity: str = "INFO",
150
+ tenant_id: str = "default",
151
+ source_component: str = "SkillPool",
152
+ duration_ms: float = 0.0,
153
+ metadata: dict[str, Any] | None = None,
154
+ trace_id: str | None = None,
155
+ request_id: str = "",
156
+ session_id: str = "",
157
+ agent_id: str = "",
158
+ **kwargs: Any,
159
+ ) -> AuditRecord:
160
+ """
161
+ Convenience function to create an audit event with auto-filled fields.
162
+
163
+ Generates event_id (UUID7-like), trace_id (if not provided), span_id,
164
+ and input/output hashes automatically. Suitable for one-liner audit logging.
165
+
166
+ Args:
167
+ action: Action performed (e.g., "register_skill", "approve_evolution")
168
+ actor: Identity performing the action
169
+ resource_type: Type of resource acted upon
170
+ resource_id: ID of resource acted upon
171
+ result: "success" / "failure" / "denied"
172
+ reason: Human-readable explanation
173
+ severity: INFO / WARN / ERROR / CRITICAL
174
+ tenant_id: Tenant identifier
175
+ source_component: Originating component
176
+ duration_ms: Operation duration in milliseconds
177
+ metadata: Arbitrary key-value metadata
178
+ trace_id: OTel trace ID (auto-generated if empty)
179
+ request_id: Request correlation ID
180
+ session_id: User session ID
181
+ **kwargs: Additional fields passed to AuditRecord
182
+
183
+ Returns:
184
+ Fully populated AuditRecord
185
+ """
186
+ now = utc_now()
187
+ event_id = kwargs.pop("event_id", str(uuid.uuid4()))
188
+ span_id = kwargs.pop("span_id", uuid.uuid4().hex[:16])
189
+ parent_span_id = kwargs.pop("parent_span_id", "")
190
+
191
+ # Hash inputs for integrity
192
+ payload = json.dumps(
193
+ {
194
+ "action": action,
195
+ "actor": actor,
196
+ "resource_type": resource_type,
197
+ "resource_id": resource_id,
198
+ "result": result,
199
+ "timestamp": isoformat_z(now),
200
+ },
201
+ sort_keys=True,
202
+ )
203
+ input_hash = hashlib.sha256(payload.encode()).hexdigest()
204
+
205
+ # Determine trace provenance
206
+ if trace_id:
207
+ resolved_trace_id = trace_id
208
+ trace_source = "w3c_passthrough"
209
+ else:
210
+ resolved_trace_id = os.urandom(16).hex()
211
+ trace_source = "skillpool_internal"
212
+
213
+ record = AuditRecord(
214
+ audit_id=f"audit-{event_id}",
215
+ event_id=event_id,
216
+ trace_id=resolved_trace_id,
217
+ span_id=span_id,
218
+ parent_span_id=parent_span_id,
219
+ event_type="skill_pool_event",
220
+ event_time=now,
221
+ created_at=isoformat_z(now),
222
+ updated_at=isoformat_z(now),
223
+ duration_ms=duration_ms,
224
+ actor=actor,
225
+ agent_id=agent_id or actor, # Default agent_id to actor if not provided
226
+ session_id=session_id,
227
+ tenant_id=tenant_id,
228
+ source_component=source_component,
229
+ action=action,
230
+ resource_type=resource_type,
231
+ resource_id=resource_id,
232
+ request_id=request_id or f"req-{uuid.uuid4().hex[:12]}",
233
+ correlation_id=kwargs.pop("correlation_id", ""),
234
+ policy_decision=kwargs.pop("policy_decision", "allow"),
235
+ decision=kwargs.pop("decision", result),
236
+ result=result,
237
+ reason=reason,
238
+ severity=severity,
239
+ input_hash=input_hash,
240
+ output_hash="",
241
+ metadata_json=json.dumps(metadata or {}, sort_keys=True, ensure_ascii=False),
242
+ retention_class=kwargs.pop("retention_class", "hot"),
243
+ compliance_tags=kwargs.pop("compliance_tags", ""),
244
+ geo_location=kwargs.pop("geo_location", ""),
245
+ trace_source=kwargs.pop("trace_source", trace_source),
246
+ signature="",
247
+ chain_index=0,
248
+ previous_hash="",
249
+ current_hash="",
250
+ )
251
+
252
+ # Compute final hash over all non-hash fields
253
+ all_fields = {
254
+ "event_id": record.event_id,
255
+ "trace_id": record.trace_id,
256
+ "span_id": record.span_id,
257
+ "action": record.action,
258
+ "actor": record.actor,
259
+ "resource_type": record.resource_type,
260
+ "resource_id": record.resource_id,
261
+ "result": record.result,
262
+ "timestamp": record.created_at,
263
+ "previous_hash": record.previous_hash,
264
+ }
265
+ record.current_hash = hashlib.sha256(json.dumps(all_fields, sort_keys=True).encode()).hexdigest()
266
+
267
+ return record
268
+
269
+
270
+ class AuditUnavailableError(Exception):
271
+ """Audit unavailable — fail closed."""
272
+
273
+ pass
274
+
275
+
276
+ class AuditLayer:
277
+ """
278
+ Audit layer — immutable evidence ledger (V4.0).
279
+
280
+ Hard rules:
281
+ - MUST NOT be bypassed
282
+ - MUST NOT be rewritten
283
+ - MUST NOT be replaced by logs
284
+ - Unavailable = fail closed for mutations
285
+ """
286
+
287
+ GENESIS_HASH = "0" * 64
288
+
289
+ def __init__(self, available: bool = True, max_entries: int = 10000, data_dir: Path | None = None) -> None:
290
+ self._available = available
291
+ self._records: list[AuditRecord] = []
292
+ self._last_hash = self.GENESIS_HASH
293
+ self._max_entries = max_entries
294
+ self._data_dir = data_dir or get_data_dir()
295
+ self._jsonl_path = self._data_dir / "audit" / "audit.jsonl"
296
+ self._load_from_jsonl()
297
+
298
+ def _rotate(self) -> None:
299
+ """Rotate audit log when entries exceed max_entries.
300
+
301
+ Keeps the most recent max_entries records, preserving hash chain integrity
302
+ by carrying forward the last pre-rotation hash as the new anchor and
303
+ recomputing hashes for all retained records.
304
+ """
305
+ if len(self._records) <= self._max_entries:
306
+ return
307
+ # Capture the hash of the last record being discarded
308
+ cutoff = len(self._records) - self._max_entries
309
+ last_discarded_hash = self._records[cutoff - 1].current_hash if cutoff > 0 else self.GENESIS_HASH
310
+ self._records = self._records[cutoff:]
311
+ # Re-anchor: carry forward the pre-rotation chain tail hash
312
+ # and recompute hashes for all retained records
313
+ if self._records:
314
+ prev_hash = last_discarded_hash
315
+ for idx, record in enumerate(self._records):
316
+ record.previous_hash = prev_hash
317
+ record.chain_index = idx
318
+ chain_payload = json.dumps(
319
+ {
320
+ "event_id": record.event_id,
321
+ "trace_id": record.trace_id,
322
+ "span_id": record.span_id,
323
+ "action": record.action,
324
+ "actor": record.actor,
325
+ "resource_type": record.resource_type,
326
+ "resource_id": record.resource_id,
327
+ "result": record.result,
328
+ "timestamp": record.created_at,
329
+ "chain_index": idx,
330
+ "previous_hash": prev_hash,
331
+ },
332
+ sort_keys=True,
333
+ )
334
+ record.current_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
335
+ record.signature = record.current_hash
336
+ prev_hash = record.current_hash
337
+ self._last_hash = prev_hash
338
+
339
+ def is_available(self) -> bool:
340
+ """Check if Audit layer is available."""
341
+ return self._available
342
+
343
+ def set_available(self, available: bool) -> None:
344
+ """Set availability (for testing)."""
345
+ self._available = available
346
+
347
+ def append(
348
+ self,
349
+ action: str,
350
+ object_id: str = "",
351
+ result: str = "success",
352
+ actor: str = "system",
353
+ tenant_id: str = "default",
354
+ source_component: str = "SkillPool",
355
+ **kwargs: Any,
356
+ ) -> str:
357
+ """
358
+ Append immutable audit record (backward-compatible interface).
359
+
360
+ Uses the upgraded log_event() internally to produce 34-field records.
361
+
362
+ Returns audit_ref for traceability.
363
+
364
+ Raises:
365
+ AuditUnavailableError if Audit is unavailable
366
+ """
367
+ if not self._available:
368
+ raise AuditUnavailableError("Audit unavailable — cannot append record")
369
+
370
+ chain_index = len(self._records)
371
+ previous_hash = self._last_hash
372
+
373
+ record = log_event(
374
+ action=action,
375
+ actor=actor,
376
+ resource_type=kwargs.pop("object_type", "skill"),
377
+ resource_id=object_id,
378
+ result=result,
379
+ reason=kwargs.pop("reason", ""),
380
+ severity=kwargs.pop("severity", "INFO"),
381
+ tenant_id=tenant_id,
382
+ source_component=source_component,
383
+ duration_ms=kwargs.pop("duration_ms", 0.0),
384
+ metadata=kwargs.pop("metadata", None),
385
+ trace_id=kwargs.pop("trace_id", ""),
386
+ request_id=kwargs.pop("request_id", ""),
387
+ session_id=kwargs.pop("session_id", ""),
388
+ agent_id=kwargs.pop("agent_id", ""),
389
+ **kwargs,
390
+ )
391
+
392
+ record.previous_hash = previous_hash
393
+ record.chain_index = chain_index
394
+
395
+ # Recompute hash with chain position
396
+ chain_payload = json.dumps(
397
+ {
398
+ "event_id": record.event_id,
399
+ "trace_id": record.trace_id,
400
+ "span_id": record.span_id,
401
+ "action": record.action,
402
+ "actor": record.actor,
403
+ "resource_type": record.resource_type,
404
+ "resource_id": record.resource_id,
405
+ "result": record.result,
406
+ "timestamp": record.created_at,
407
+ "chain_index": chain_index,
408
+ "previous_hash": previous_hash,
409
+ },
410
+ sort_keys=True,
411
+ )
412
+ record.current_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
413
+ record.signature = record.current_hash
414
+
415
+ self._records.append(record)
416
+ self._last_hash = record.current_hash
417
+
418
+ self._rotate()
419
+ self._write_to_jsonl(record)
420
+ return record.audit_id
421
+
422
+ def log_event_record(self, action: str, **kwargs: Any) -> AuditRecord:
423
+ """
424
+ Log a fully-detailed event (new V4.0 interface).
425
+
426
+ Thin wrapper around the module-level log_event() that also
427
+ appends to the chain.
428
+
429
+ Args:
430
+ action: Action name
431
+ **kwargs: All AuditRecord fields
432
+
433
+ Returns:
434
+ The created AuditRecord
435
+ """
436
+ if not self._available:
437
+ raise AuditUnavailableError("Audit unavailable — cannot log event")
438
+
439
+ record = log_event(action=action, **kwargs)
440
+ chain_index = len(self._records)
441
+ record.previous_hash = self._last_hash
442
+ record.chain_index = chain_index
443
+
444
+ chain_payload = json.dumps(
445
+ {
446
+ "event_id": record.event_id,
447
+ "trace_id": record.trace_id,
448
+ "span_id": record.span_id,
449
+ "action": record.action,
450
+ "actor": record.actor,
451
+ "resource_type": record.resource_type,
452
+ "resource_id": record.resource_id,
453
+ "result": record.result,
454
+ "timestamp": record.created_at,
455
+ "chain_index": chain_index,
456
+ "previous_hash": self._last_hash,
457
+ },
458
+ sort_keys=True,
459
+ )
460
+ record.current_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
461
+ record.signature = record.current_hash
462
+
463
+ self._records.append(record)
464
+ self._last_hash = record.current_hash
465
+
466
+ self._write_to_jsonl(record)
467
+ return record
468
+
469
+ def get_records(self, object_id: str | None = None) -> list[AuditRecord]:
470
+ """Get audit records, optionally filtered by resource_id (or object_id compat)."""
471
+ if object_id:
472
+ return [r for r in self._records if r.resource_id == object_id]
473
+ return list(self._records)
474
+
475
+ def get_record_count(self) -> int:
476
+ """Return total number of audit records."""
477
+ return len(self._records)
478
+
479
+ def verify_integrity(self) -> bool:
480
+ """
481
+ Verify hash chain integrity of in-memory audit records.
482
+
483
+ Checks:
484
+ 1. Each record's current_hash matches recomputed hash from its content
485
+ 2. Each record's previous_hash matches the preceding record's current_hash
486
+
487
+ After rotation, the first record's previous_hash may reference a
488
+ pre-rotation chain tail rather than GENESIS_HASH — we accept it
489
+ as a valid anchor.
490
+ """
491
+ if not self._records:
492
+ return True
493
+
494
+ for idx, record in enumerate(self._records):
495
+ # Recompute hash from record content
496
+ chain_payload = json.dumps(
497
+ {
498
+ "event_id": record.event_id,
499
+ "trace_id": record.trace_id,
500
+ "span_id": record.span_id,
501
+ "action": record.action,
502
+ "actor": record.actor,
503
+ "resource_type": record.resource_type,
504
+ "resource_id": record.resource_id,
505
+ "result": record.result,
506
+ "timestamp": record.created_at,
507
+ "chain_index": record.chain_index,
508
+ "previous_hash": record.previous_hash,
509
+ },
510
+ sort_keys=True,
511
+ )
512
+ expected_hash = hashlib.sha256(chain_payload.encode()).hexdigest()
513
+
514
+ if record.current_hash != expected_hash:
515
+ return False
516
+
517
+ # Verify chain link (skip first record — its previous_hash
518
+ # may reference pre-rotation tail)
519
+ if idx > 0:
520
+ if record.previous_hash != self._records[idx - 1].current_hash:
521
+ return False
522
+
523
+ return True
524
+
525
+ def _write_to_jsonl(self, record: AuditRecord) -> None:
526
+ """Append an audit record to JSONL file with fsync for durability."""
527
+ try:
528
+ self._jsonl_path.parent.mkdir(parents=True, exist_ok=True)
529
+ import dataclasses
530
+
531
+ d = dataclasses.asdict(record)
532
+ # Convert datetime objects to ISO strings
533
+ et = d.get("event_time")
534
+ if isinstance(et, datetime):
535
+ d["event_time"] = isoformat_z(et)
536
+ line = json.dumps(d, ensure_ascii=False, sort_keys=True) + "\n"
537
+ with open(self._jsonl_path, "a", encoding="utf-8") as f:
538
+ f.write(line)
539
+ f.flush()
540
+ os.fsync(f.fileno())
541
+ except OSError:
542
+ pass # Disk persistence is best-effort
543
+
544
+ def _load_from_jsonl(self) -> None:
545
+ """Load audit records from JSONL file on startup, rebuilding hash chain."""
546
+ if not self._jsonl_path.exists():
547
+ return
548
+ try:
549
+ with open(self._jsonl_path, "r", encoding="utf-8") as f:
550
+ for line in f:
551
+ line = line.strip()
552
+ if not line:
553
+ continue
554
+ data = json.loads(line)
555
+ record = AuditRecord(
556
+ audit_id=data.get("audit_id", ""),
557
+ event_id=data.get("event_id", ""),
558
+ trace_id=data.get("trace_id", ""),
559
+ span_id=data.get("span_id", ""),
560
+ parent_span_id=data.get("parent_span_id", ""),
561
+ event_type=data.get("event_type", "skill_pool_event"),
562
+ event_time=data.get("event_time"),
563
+ created_at=data.get("created_at", ""),
564
+ updated_at=data.get("updated_at", ""),
565
+ duration_ms=data.get("duration_ms", 0.0),
566
+ actor=data.get("actor", "system"),
567
+ agent_id=data.get("agent_id", ""),
568
+ session_id=data.get("session_id", ""),
569
+ tenant_id=data.get("tenant_id", "default"),
570
+ source_component=data.get("source_component", "SkillPool"),
571
+ action=data.get("action", ""),
572
+ resource_type=data.get("resource_type", "skill"),
573
+ resource_id=data.get("resource_id", ""),
574
+ request_id=data.get("request_id", ""),
575
+ correlation_id=data.get("correlation_id", ""),
576
+ policy_decision=data.get("policy_decision", "allow"),
577
+ decision=data.get("decision", ""),
578
+ result=data.get("result", "success"),
579
+ reason=data.get("reason", ""),
580
+ severity=data.get("severity", "INFO"),
581
+ previous_hash=data.get("previous_hash", ""),
582
+ current_hash=data.get("current_hash", ""),
583
+ chain_index=data.get("chain_index", 0),
584
+ signature=data.get("signature", ""),
585
+ input_hash=data.get("input_hash", ""),
586
+ output_hash=data.get("output_hash", ""),
587
+ metadata_json=data.get("metadata_json", "{}"),
588
+ retention_class=data.get("retention_class", "hot"),
589
+ compliance_tags=data.get("compliance_tags", ""),
590
+ geo_location=data.get("geo_location", ""),
591
+ trace_source=data.get("trace_source", ""),
592
+ )
593
+ self._records.append(record)
594
+ if record.current_hash:
595
+ self._last_hash = record.current_hash
596
+ # Enforce max_entries after load
597
+ if len(self._records) > self._max_entries:
598
+ cutoff = len(self._records) - self._max_entries
599
+ self._records = self._records[cutoff:]
600
+ if self._records:
601
+ self._last_hash = self._records[-1].current_hash
602
+ except (json.JSONDecodeError, OSError):
603
+ pass # Disk load is best-effort
604
+
605
+ def export_otel_traces(self) -> list[dict[str, Any]]:
606
+ """
607
+ Export records in OpenTelemetry-compatible format.
608
+
609
+ Returns:
610
+ List of dicts with OTel standard fields: traceId, spanId, name, attributes, etc.
611
+ """
612
+ from datetime import timezone
613
+
614
+ traces = []
615
+ for record in self._records:
616
+ event_time = record.event_time
617
+ if isinstance(event_time, str):
618
+ # Loaded from JSONL — parse back to datetime
619
+ try:
620
+ event_time = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
621
+ except (ValueError, AttributeError):
622
+ event_time = datetime.now(UTC)
623
+ if event_time.tzinfo is None:
624
+ event_time = event_time.replace(tzinfo=timezone.utc)
625
+
626
+ traces.append(
627
+ {
628
+ "traceId": record.trace_id,
629
+ "spanId": record.span_id,
630
+ "parentSpanId": record.parent_span_id or None,
631
+ "name": f"{record.source_component}.{record.action}",
632
+ "kind": "INTERNAL",
633
+ "startTimeUnixNano": str(int(event_time.timestamp() * 1e9)),
634
+ "endTimeUnixNano": str(int((event_time.timestamp() + record.duration_ms / 1000) * 1e9)),
635
+ "attributes": {
636
+ "audit.id": record.audit_id,
637
+ "event.id": record.event_id,
638
+ "actor": record.actor,
639
+ "action": record.action,
640
+ "resource.type": record.resource_type,
641
+ "resource.id": record.resource_id,
642
+ "decision": record.decision,
643
+ "result": record.result,
644
+ "severity": record.severity,
645
+ "tenant.id": record.tenant_id,
646
+ "compliance.tags": record.compliance_tags,
647
+ },
648
+ "status": {"code": 1 if record.result == "success" else 2},
649
+ }
650
+ )
651
+ return traces
@@ -0,0 +1,16 @@
1
+ """SkillPool Bridge — WAL, freeze detection, and maintenance for registry integrity."""
2
+
3
+ from .freeze_detector import FreezeDetector, FreezeReport, FreezeStatus
4
+ from .maintenance import MaintenanceCron, MaintenanceResult
5
+ from .wal_manager import WALEntry, WALEntryType, WALManager
6
+
7
+ __all__ = [
8
+ "WALManager",
9
+ "WALEntry",
10
+ "WALEntryType",
11
+ "FreezeDetector",
12
+ "FreezeReport",
13
+ "FreezeStatus",
14
+ "MaintenanceCron",
15
+ "MaintenanceResult",
16
+ ]