chp-adapter-audit 0.8.0__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.
@@ -0,0 +1,23 @@
1
+ node_modules/
2
+ dist/
3
+ .yalc/
4
+ yalc.lock
5
+ *.tgz
6
+ .env
7
+ .env.*
8
+ coverage/
9
+ dashboard/
10
+ components/
11
+ .tech-hub-cache/
12
+ __pycache__/
13
+ *.py[cod]
14
+ .pytest_cache/
15
+ .chp/
16
+ .DS_Store
17
+ .coverage
18
+ .forge/
19
+ .hypothesis/
20
+ .claude/
21
+
22
+ # chp-agent evidence store
23
+ .chp-agent/sessions.sqlite
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: chp-adapter-audit
3
+ Version: 0.8.0
4
+ Summary: CHP capability adapter — queryable audit log over the host evidence store
5
+ Author: Auxo
6
+ License: Apache-2.0
7
+ Keywords: adapter,audit,capability-host-protocol,chp,evidence,governance
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: chp-core>=0.7.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
File without changes
@@ -0,0 +1,20 @@
1
+ """chp-adapter-audit — queryable audit log over the CHP host evidence store.
2
+
3
+ Exposes three capabilities: ``query_invocations``, ``get_invocation``, and
4
+ ``stats``. Uses ``on_register(host)`` to capture the host's evidence store.
5
+ An injectable ``store`` on the config allows tests to bypass the host.
6
+
7
+ Usage::
8
+
9
+ from chp_core import LocalCapabilityHost, register_adapter
10
+ from chp_adapter_audit import AuditAdapter, AuditConfig
11
+
12
+ host = LocalCapabilityHost()
13
+ register_adapter(host, AuditAdapter()) # binds to host.store automatically
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .adapter import AuditAdapter, AuditConfig
19
+
20
+ __all__ = ["AuditAdapter", "AuditConfig"]
@@ -0,0 +1,390 @@
1
+ """AuditAdapter — queryable governance audit log over the CHP evidence store.
2
+
3
+ Three capabilities:
4
+
5
+ * ``query_invocations`` — filter by capability_id, outcome, time window, limit;
6
+ returns per-invocation summaries (never raw event payloads).
7
+ * ``get_invocation`` — fetch all events for one invocation_id; returns only
8
+ metadata (event_type, timestamp, outcome) to avoid leaking sensitive payloads
9
+ that may have been stored by other adapters.
10
+ * ``stats`` — aggregate counts by outcome and by capability over a time window.
11
+
12
+ Evidence hygiene: only counts, IDs, event_types, and timestamps are stored or
13
+ returned. The stored event payloads (which may carry PII, tokens, or secrets
14
+ from other adapters) are NEVER included in audit evidence or return values.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import defaultdict
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from chp_core import BaseAdapter, capability
24
+
25
+ _EMITS = ["audit_query", "audit_result", "audit_error"]
26
+
27
+
28
+ @dataclass
29
+ class AuditConfig:
30
+ """Config for AuditAdapter.
31
+
32
+ ``max_results`` caps the maximum rows returned by any query.
33
+ ``store`` is injectable for tests (bypasses host.store binding).
34
+ """
35
+
36
+ max_results: int = 1000
37
+ store: Any = None
38
+
39
+
40
+ class AuditAdapter(BaseAdapter):
41
+ """Static adapter exposing the host's evidence store as governed capabilities."""
42
+
43
+ adapter_id = "chp.adapters.audit"
44
+ adapter_name = "Audit Log"
45
+ adapter_description = "Query the CHP evidence store with filters and aggregate stats."
46
+ adapter_category = "governance"
47
+ adapter_tags = ["audit", "governance", "evidence", "meta"]
48
+
49
+ def __init__(self, config: AuditConfig | None = None) -> None:
50
+ self._config = config or AuditConfig()
51
+ self._store: Any = self._config.store # None until on_register if not injected
52
+
53
+ def on_register(self, host: Any) -> None:
54
+ if self._store is None:
55
+ self._store = host.store
56
+
57
+ # ------------------------------------------------------------------
58
+ # query_invocations
59
+ # ------------------------------------------------------------------
60
+
61
+ @capability(
62
+ id="chp.adapters.audit.query_invocations",
63
+ version="1.0.0",
64
+ description="Query invocation records from the audit log with optional filters.",
65
+ category="governance",
66
+ risk="low",
67
+ input_schema={
68
+ "type": "object",
69
+ "properties": {
70
+ "capability_id": {"type": "string", "description": "Filter by exact capability ID."},
71
+ "outcome": {
72
+ "type": "string",
73
+ "enum": ["success", "failure", "denied", "skipped"],
74
+ "description": "Filter by invocation outcome.",
75
+ },
76
+ "since": {"type": "string", "description": "ISO-8601 lower bound (inclusive)."},
77
+ "until": {"type": "string", "description": "ISO-8601 upper bound (inclusive)."},
78
+ "limit": {"type": "integer", "minimum": 1, "default": 100},
79
+ },
80
+ "additionalProperties": False,
81
+ },
82
+ emits=_EMITS,
83
+ tags=["audit", "governance"],
84
+ )
85
+ async def query_invocations(self, ctx: Any, payload: dict) -> dict:
86
+ if self._store is None:
87
+ ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
88
+ raise RuntimeError("AuditAdapter: store not bound — register with a host first")
89
+
90
+ limit = min(payload.get("limit") or 100, self._config.max_results)
91
+ cap_id = payload.get("capability_id")
92
+ outcome = payload.get("outcome")
93
+ since = payload.get("since")
94
+ until = payload.get("until")
95
+
96
+ ctx.emit("audit_query", {
97
+ "op": "query_invocations",
98
+ "filters": {k: v for k, v in {
99
+ "capability_id": cap_id, "outcome": outcome, "since": since, "until": until,
100
+ }.items() if v is not None},
101
+ "limit": limit,
102
+ }, redacted=False)
103
+
104
+ events = self._store.query(
105
+ capability_id=cap_id,
106
+ outcome=outcome,
107
+ since=since,
108
+ until=until,
109
+ limit=limit * 10, # over-fetch to group by invocation
110
+ )
111
+
112
+ # Exclude the current audit invocation so it doesn't appear in results
113
+ current_inv_id = ctx.envelope.invocation_id
114
+ events = [e for e in events if e.get("invocation_id") != current_inv_id]
115
+
116
+ invocations = _group_by_invocation(events, limit)
117
+
118
+ ctx.emit("audit_result", {
119
+ "op": "query_invocations",
120
+ "total": len(invocations),
121
+ }, redacted=False)
122
+
123
+ return {"invocations": invocations, "total": len(invocations)}
124
+
125
+ # ------------------------------------------------------------------
126
+ # get_invocation
127
+ # ------------------------------------------------------------------
128
+
129
+ @capability(
130
+ id="chp.adapters.audit.get_invocation",
131
+ version="1.0.0",
132
+ description="Fetch event metadata for a specific invocation ID.",
133
+ category="governance",
134
+ risk="low",
135
+ input_schema={
136
+ "type": "object",
137
+ "properties": {
138
+ "invocation_id": {"type": "string"},
139
+ },
140
+ "required": ["invocation_id"],
141
+ "additionalProperties": False,
142
+ },
143
+ emits=_EMITS,
144
+ tags=["audit", "governance"],
145
+ )
146
+ async def get_invocation(self, ctx: Any, payload: dict) -> dict:
147
+ if self._store is None:
148
+ ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
149
+ raise RuntimeError("AuditAdapter: store not bound")
150
+
151
+ invocation_id = payload["invocation_id"]
152
+
153
+ ctx.emit("audit_query", {
154
+ "op": "get_invocation",
155
+ "invocation_id": invocation_id,
156
+ }, redacted=False)
157
+
158
+ events = self._store.by_invocation(invocation_id)
159
+
160
+ if not events:
161
+ ctx.emit("audit_error", {
162
+ "reason": "not_found", "invocation_id": invocation_id,
163
+ }, redacted=False)
164
+ raise ValueError(f"Invocation not found: {invocation_id!r}")
165
+
166
+ # Strip payloads — only metadata (event_type, timestamp, outcome) returned
167
+ stripped = [
168
+ {
169
+ "event_id": e.get("event_id"),
170
+ "event_type": e.get("event_type"),
171
+ "timestamp": e.get("timestamp"),
172
+ "outcome": e.get("outcome"),
173
+ "sequence": e.get("sequence"),
174
+ }
175
+ for e in events
176
+ ]
177
+
178
+ ctx.emit("audit_result", {
179
+ "op": "get_invocation",
180
+ "invocation_id": invocation_id,
181
+ "event_count": len(stripped),
182
+ }, redacted=False)
183
+
184
+ return {
185
+ "invocation_id": invocation_id,
186
+ "events": stripped,
187
+ "event_count": len(stripped),
188
+ }
189
+
190
+ # ------------------------------------------------------------------
191
+ # token_report
192
+ # ------------------------------------------------------------------
193
+
194
+ @capability(
195
+ id="chp.adapters.audit.token_report",
196
+ version="1.0.0",
197
+ description=(
198
+ "Aggregate sovereign inference token usage by model. Returns per-model "
199
+ "token totals, call counts, backfill summary (calls before token tracking "
200
+ "was added on 2026-06-17), and estimated frontier-equivalent cost."
201
+ ),
202
+ category="observability",
203
+ risk="low",
204
+ input_schema={
205
+ "type": "object",
206
+ "properties": {
207
+ "since": {"type": "string", "description": "ISO-8601 lower bound."},
208
+ "until": {"type": "string", "description": "ISO-8601 upper bound."},
209
+ "frontier_price_per_1m_input": {
210
+ "type": "number",
211
+ "description": "Frontier input token price per 1M tokens (default 3.0, Claude Sonnet rate).",
212
+ },
213
+ "frontier_price_per_1m_output": {
214
+ "type": "number",
215
+ "description": "Frontier output token price per 1M tokens (default 15.0, Claude Sonnet rate).",
216
+ },
217
+ },
218
+ "additionalProperties": False,
219
+ },
220
+ emits=_EMITS,
221
+ tags=["audit", "governance", "tokens", "observability"],
222
+ )
223
+ async def token_report(self, ctx: Any, payload: dict) -> dict:
224
+ if self._store is None:
225
+ ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
226
+ raise RuntimeError("AuditAdapter: store not bound")
227
+
228
+ since = payload.get("since")
229
+ until = payload.get("until")
230
+ input_price = float(payload.get("frontier_price_per_1m_input") or 3.0)
231
+ output_price = float(payload.get("frontier_price_per_1m_output") or 15.0)
232
+
233
+ ctx.emit("audit_query", {"op": "token_report", "since": since, "until": until}, redacted=False)
234
+
235
+ events = self._store.query(
236
+ capability_id="chp.adapters.http.request",
237
+ since=since,
238
+ until=until,
239
+ )
240
+ http_responses = [e for e in events if e.get("event_type") == "http_response"]
241
+
242
+ with_tokens = [e for e in http_responses if "prompt_tokens" in e.get("payload", {})]
243
+ without_tokens = [e for e in http_responses if "prompt_tokens" not in e.get("payload", {})]
244
+
245
+ by_model: dict[str, dict] = {}
246
+ for e in with_tokens:
247
+ p = e["payload"]
248
+ m = p.get("model", "unknown")
249
+ rec = by_model.setdefault(m, {"model": m, "prompt_tokens": 0, "completion_tokens": 0, "calls": 0})
250
+ rec["prompt_tokens"] += p.get("prompt_tokens", 0)
251
+ rec["completion_tokens"] += p.get("completion_tokens", 0)
252
+ rec["calls"] += 1
253
+
254
+ total_prompt = sum(r["prompt_tokens"] for r in by_model.values())
255
+ total_completion = sum(r["completion_tokens"] for r in by_model.values())
256
+ frontier_cost = (
257
+ total_prompt / 1_000_000 * input_price
258
+ + total_completion / 1_000_000 * output_price
259
+ )
260
+
261
+ earliest_tracked = min(
262
+ (e["timestamp"] for e in with_tokens if e.get("timestamp")), default=None
263
+ )
264
+
265
+ result = {
266
+ "window": {"since": since, "until": until},
267
+ "sovereign": {
268
+ "total_prompt_tokens": total_prompt,
269
+ "total_completion_tokens": total_completion,
270
+ "total_tokens": total_prompt + total_completion,
271
+ "by_model": sorted(by_model.values(), key=lambda r: -r["calls"]),
272
+ },
273
+ "backfill": {
274
+ "calls_without_token_data": len(without_tokens),
275
+ "note": "Calls before token tracking (2026-06-17). Counts only, no token data.",
276
+ "earliest_tracked": earliest_tracked,
277
+ },
278
+ "estimated_frontier_cost_usd": round(frontier_cost, 4),
279
+ "pricing_basis": f"${input_price}/1M input, ${output_price}/1M output (Claude Sonnet rates)",
280
+ }
281
+
282
+ ctx.emit("audit_result", {
283
+ "op": "token_report",
284
+ "total_tokens": total_prompt + total_completion,
285
+ "models": list(by_model.keys()),
286
+ }, redacted=False)
287
+ return result
288
+
289
+ # ------------------------------------------------------------------
290
+ # stats
291
+ # ------------------------------------------------------------------
292
+
293
+ @capability(
294
+ id="chp.adapters.audit.stats",
295
+ version="1.0.0",
296
+ description="Aggregate invocation counts by outcome and capability over a time window.",
297
+ category="governance",
298
+ risk="low",
299
+ input_schema={
300
+ "type": "object",
301
+ "properties": {
302
+ "since": {"type": "string", "description": "ISO-8601 lower bound."},
303
+ "until": {"type": "string", "description": "ISO-8601 upper bound."},
304
+ },
305
+ "additionalProperties": False,
306
+ },
307
+ emits=_EMITS,
308
+ tags=["audit", "governance"],
309
+ )
310
+ async def stats(self, ctx: Any, payload: dict) -> dict:
311
+ if self._store is None:
312
+ ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
313
+ raise RuntimeError("AuditAdapter: store not bound")
314
+
315
+ since = payload.get("since")
316
+ until = payload.get("until")
317
+
318
+ ctx.emit("audit_query", {
319
+ "op": "stats",
320
+ "since": since,
321
+ "until": until,
322
+ }, redacted=False)
323
+
324
+ events = self._store.query(since=since, until=until)
325
+
326
+ # Exclude the current audit invocation
327
+ current_inv_id = ctx.envelope.invocation_id
328
+ events = [e for e in events if e.get("invocation_id") != current_inv_id]
329
+
330
+ # Count only terminal lifecycle events (execution_started = one per invocation)
331
+ started = [e for e in events if e.get("event_type") == "execution_started"]
332
+ total = len(started)
333
+
334
+ by_outcome: dict[str, int] = defaultdict(int)
335
+ by_cap: dict[str, int] = defaultdict(int)
336
+ for e in started:
337
+ out = e.get("outcome") or "unknown"
338
+ by_outcome[out] += 1
339
+ cap = e.get("capability_id") or "unknown"
340
+ by_cap[cap] += 1
341
+
342
+ error_rate = (by_outcome.get("failure", 0) / total) if total > 0 else 0.0
343
+ by_capability = sorted(
344
+ [{"id": k, "count": v} for k, v in by_cap.items()],
345
+ key=lambda x: x["count"],
346
+ reverse=True,
347
+ )
348
+
349
+ ctx.emit("audit_result", {
350
+ "op": "stats",
351
+ "total_invocations": total,
352
+ }, redacted=False)
353
+
354
+ return {
355
+ "total_invocations": total,
356
+ "by_outcome": dict(by_outcome),
357
+ "by_capability": by_capability,
358
+ "error_rate": round(error_rate, 4),
359
+ }
360
+
361
+
362
+ # --------------------------------------------------------------------------
363
+ # Helpers
364
+ # --------------------------------------------------------------------------
365
+
366
+ def _group_by_invocation(events: list[dict], limit: int) -> list[dict]:
367
+ """Group a flat event list into per-invocation summaries, capped at ``limit``."""
368
+ seen: dict[str, dict] = {}
369
+ for e in events:
370
+ inv_id = e.get("invocation_id")
371
+ if inv_id is None:
372
+ continue
373
+ if inv_id not in seen:
374
+ if len(seen) >= limit:
375
+ continue
376
+ seen[inv_id] = {
377
+ "invocation_id": inv_id,
378
+ "capability_id": e.get("capability_id"),
379
+ "started_at": e.get("timestamp"),
380
+ "outcome": None,
381
+ "event_count": 0,
382
+ }
383
+ seen[inv_id]["event_count"] += 1
384
+ # Capture outcome from terminal lifecycle event
385
+ if e.get("event_type") in ("execution_completed", "execution_failed", "execution_denied"):
386
+ seen[inv_id]["outcome"] = e.get("outcome")
387
+ if e.get("timestamp"):
388
+ seen[inv_id]["completed_at"] = e.get("timestamp")
389
+
390
+ return list(seen.values())
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-audit"
7
+ version = "0.8.0"
8
+ description = "CHP capability adapter — queryable audit log over the host evidence store"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Auxo" }]
13
+ keywords = ["chp", "capability-host-protocol", "audit", "evidence", "governance", "adapter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "chp-core>=0.7.0",
27
+ ]
28
+
29
+ [project.entry-points."chp.adapters"]
30
+ audit = "chp_adapter_audit:AuditAdapter"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8.0"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["."]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["chp_adapter_audit"]
@@ -0,0 +1,377 @@
1
+ """Tests for chp_adapter_audit.adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from chp_core import LocalCapabilityHost, capability, register_adapter
8
+ from chp_core.store import SQLiteEvidenceStore
9
+
10
+ from chp_adapter_audit import AuditAdapter, AuditConfig
11
+
12
+
13
+ # --------------------------------------------------------------------------
14
+ # Minimal echo adapter to generate invocation evidence
15
+ # --------------------------------------------------------------------------
16
+
17
+ class _EchoAdapter:
18
+ adapter_id = "chp.adapters.echo"
19
+
20
+ @capability(
21
+ id="chp.adapters.echo.ping",
22
+ version="1.0.0",
23
+ description="Echo.",
24
+ risk="low",
25
+ input_schema={
26
+ "type": "object",
27
+ "properties": {"msg": {"type": "string"}},
28
+ "additionalProperties": False,
29
+ },
30
+ )
31
+ async def ping(self, ctx, payload):
32
+ return {"pong": payload.get("msg", "")}
33
+
34
+ @capability(
35
+ id="chp.adapters.echo.fail",
36
+ version="1.0.0",
37
+ description="Always fails.",
38
+ risk="low",
39
+ input_schema={"type": "object", "properties": {}, "additionalProperties": False},
40
+ )
41
+ async def fail(self, ctx, payload):
42
+ raise RuntimeError("forced failure")
43
+
44
+ def capabilities(self):
45
+ from chp_core.adapters import HostedCapability
46
+ from chp_core.decorators import adapt_callable, get_capability_descriptor
47
+ import inspect
48
+ for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
49
+ desc = get_capability_descriptor(method.__func__)
50
+ if desc is not None:
51
+ yield HostedCapability(descriptor=desc, handler=adapt_callable(method))
52
+
53
+
54
+ def _make_host():
55
+ host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
56
+ register_adapter(host, _EchoAdapter())
57
+ register_adapter(host, AuditAdapter())
58
+ return host
59
+
60
+
61
+ def _cap_events(store):
62
+ return [e for e in store.all() if "capability_uri" not in e["payload"]]
63
+
64
+
65
+ # --------------------------------------------------------------------------
66
+ # 1. Shaping
67
+ # --------------------------------------------------------------------------
68
+
69
+ class TestShaping:
70
+ def test_capability_ids(self):
71
+ host = _make_host()
72
+ ids = {c.descriptor.id for c in AuditAdapter().capabilities()}
73
+ assert ids == {
74
+ "chp.adapters.audit.query_invocations",
75
+ "chp.adapters.audit.get_invocation",
76
+ "chp.adapters.audit.stats",
77
+ "chp.adapters.audit.token_report",
78
+ }
79
+
80
+ def test_all_risk_low(self):
81
+ for cap in AuditAdapter().capabilities():
82
+ assert cap.descriptor.risk == "low"
83
+
84
+
85
+ # --------------------------------------------------------------------------
86
+ # 2. query_invocations
87
+ # --------------------------------------------------------------------------
88
+
89
+ class TestQueryInvocations:
90
+ def test_empty_store(self):
91
+ host = _make_host()
92
+ r = host.invoke("chp.adapters.audit.query_invocations", {})
93
+ assert r.outcome == "success"
94
+ assert r.data["total"] == 0
95
+ assert r.data["invocations"] == []
96
+
97
+ def test_returns_after_invocations(self):
98
+ host = _make_host()
99
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
100
+ host.invoke("chp.adapters.echo.ping", {"msg": "b"})
101
+ r = host.invoke("chp.adapters.audit.query_invocations", {})
102
+ assert r.data["total"] == 2
103
+
104
+ def test_filter_by_capability_id(self):
105
+ host = _make_host()
106
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
107
+ host.invoke("chp.adapters.echo.fail", {})
108
+ r = host.invoke("chp.adapters.audit.query_invocations",
109
+ {"capability_id": "chp.adapters.echo.ping"})
110
+ assert r.data["total"] == 1
111
+ assert r.data["invocations"][0]["capability_id"] == "chp.adapters.echo.ping"
112
+
113
+ def test_filter_by_outcome(self):
114
+ host = _make_host()
115
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
116
+ host.invoke("chp.adapters.echo.fail", {})
117
+ r = host.invoke("chp.adapters.audit.query_invocations", {"outcome": "success"})
118
+ # Only ping should appear
119
+ assert all(inv["outcome"] in ("success", None) for inv in r.data["invocations"])
120
+
121
+ def test_invocation_summary_keys(self):
122
+ host = _make_host()
123
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
124
+ r = host.invoke("chp.adapters.audit.query_invocations", {})
125
+ inv = r.data["invocations"][0]
126
+ assert "invocation_id" in inv
127
+ assert "capability_id" in inv
128
+ assert "event_count" in inv
129
+
130
+ def test_payloads_never_in_invocation_summary(self):
131
+ host = _make_host()
132
+ host.invoke("chp.adapters.echo.ping", {"msg": "super_secret_value"})
133
+ r = host.invoke("chp.adapters.audit.query_invocations", {})
134
+ dump = str(r.data["invocations"])
135
+ assert "super_secret_value" not in dump
136
+
137
+ def test_extra_field_denied(self):
138
+ host = _make_host()
139
+ r = host.invoke("chp.adapters.audit.query_invocations", {"injected": "bad"})
140
+ assert r.outcome == "denied"
141
+
142
+
143
+ # --------------------------------------------------------------------------
144
+ # 3. get_invocation
145
+ # --------------------------------------------------------------------------
146
+
147
+ class TestGetInvocation:
148
+ def test_returns_event_metadata(self):
149
+ host = _make_host()
150
+ result = host.invoke("chp.adapters.echo.ping", {"msg": "test"})
151
+ inv_id = result.invocation_id
152
+
153
+ r = host.invoke("chp.adapters.audit.get_invocation", {"invocation_id": inv_id})
154
+ assert r.outcome == "success"
155
+ assert r.data["invocation_id"] == inv_id
156
+ assert r.data["event_count"] > 0
157
+
158
+ def test_events_have_type_and_timestamp(self):
159
+ host = _make_host()
160
+ result = host.invoke("chp.adapters.echo.ping", {"msg": "test"})
161
+ r = host.invoke("chp.adapters.audit.get_invocation",
162
+ {"invocation_id": result.invocation_id})
163
+ evt = r.data["events"][0]
164
+ assert "event_type" in evt
165
+ assert "timestamp" in evt
166
+
167
+ def test_payloads_stripped_from_events(self):
168
+ host = _make_host()
169
+ result = host.invoke("chp.adapters.echo.ping", {"msg": "another_secret"})
170
+ r = host.invoke("chp.adapters.audit.get_invocation",
171
+ {"invocation_id": result.invocation_id})
172
+ dump = str(r.data["events"])
173
+ assert "another_secret" not in dump
174
+ assert "payload" not in dump
175
+
176
+ def test_not_found_fails(self):
177
+ host = _make_host()
178
+ r = host.invoke("chp.adapters.audit.get_invocation",
179
+ {"invocation_id": "nonexistent-id"})
180
+ assert r.outcome == "failure"
181
+
182
+
183
+ # --------------------------------------------------------------------------
184
+ # 4. stats
185
+ # --------------------------------------------------------------------------
186
+
187
+ class TestStats:
188
+ def test_empty_store_returns_zeros(self):
189
+ host = _make_host()
190
+ r = host.invoke("chp.adapters.audit.stats", {})
191
+ assert r.outcome == "success"
192
+ assert r.data["total_invocations"] == 0
193
+ assert r.data["error_rate"] == 0.0
194
+
195
+ def test_counts_invocations(self):
196
+ host = _make_host()
197
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
198
+ host.invoke("chp.adapters.echo.ping", {"msg": "b"})
199
+ host.invoke("chp.adapters.echo.fail", {})
200
+ r = host.invoke("chp.adapters.audit.stats", {})
201
+ assert r.data["total_invocations"] >= 3
202
+
203
+ def test_by_outcome_present(self):
204
+ host = _make_host()
205
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
206
+ r = host.invoke("chp.adapters.audit.stats", {})
207
+ assert "by_outcome" in r.data
208
+
209
+ def test_by_capability_sorted_desc(self):
210
+ host = _make_host()
211
+ host.invoke("chp.adapters.echo.ping", {"msg": "a"})
212
+ host.invoke("chp.adapters.echo.ping", {"msg": "b"})
213
+ host.invoke("chp.adapters.echo.fail", {})
214
+ r = host.invoke("chp.adapters.audit.stats", {})
215
+ by_cap = r.data["by_capability"]
216
+ if len(by_cap) > 1:
217
+ assert by_cap[0]["count"] >= by_cap[1]["count"]
218
+
219
+ def test_error_rate_computed(self):
220
+ host = _make_host()
221
+ host.invoke("chp.adapters.echo.ping", {"msg": "ok"})
222
+ host.invoke("chp.adapters.echo.fail", {})
223
+ r = host.invoke("chp.adapters.audit.stats", {})
224
+ assert 0.0 <= r.data["error_rate"] <= 1.0
225
+
226
+
227
+ # --------------------------------------------------------------------------
228
+ # 5. Evidence from audit adapter itself
229
+ # --------------------------------------------------------------------------
230
+
231
+ class TestAuditEvidence:
232
+ def _audit_events(self, store):
233
+ return [
234
+ e for e in store.all()
235
+ if e.get("capability_id", "").startswith("chp.adapters.audit")
236
+ and e.get("event_type") not in ("execution_started", "execution_completed", "execution_failed")
237
+ ]
238
+
239
+ def test_query_emits_audit_events(self):
240
+ host = _make_host()
241
+ host.invoke("chp.adapters.audit.query_invocations", {})
242
+ types = [e["event_type"] for e in self._audit_events(host.store)]
243
+ assert "audit_query" in types
244
+ assert "audit_result" in types
245
+
246
+ def test_stats_emits_audit_events(self):
247
+ host = _make_host()
248
+ host.invoke("chp.adapters.audit.stats", {})
249
+ types = [e["event_type"] for e in self._audit_events(host.store)]
250
+ assert "audit_query" in types
251
+ assert "audit_result" in types
252
+
253
+
254
+ # --------------------------------------------------------------------------
255
+ # 6. Injectable store
256
+ # --------------------------------------------------------------------------
257
+
258
+ class TestInjectableStore:
259
+ def test_injectable_store_works(self):
260
+ store = SQLiteEvidenceStore(":memory:")
261
+ # Pre-populate store by creating a host and making calls
262
+ host = LocalCapabilityHost(store=store)
263
+ register_adapter(host, _EchoAdapter())
264
+ host.invoke("chp.adapters.echo.ping", {"msg": "hello"})
265
+
266
+ # Now create audit adapter with injected store (no host registration needed)
267
+ audit = AuditAdapter(AuditConfig(store=store))
268
+ audit_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
269
+ register_adapter(audit_host, audit)
270
+
271
+ r = audit_host.invoke("chp.adapters.audit.query_invocations", {})
272
+ assert r.outcome == "success"
273
+ assert r.data["total"] >= 1
274
+
275
+
276
+ # --------------------------------------------------------------------------
277
+ # 7. token_report
278
+ # --------------------------------------------------------------------------
279
+
280
+ def _seed_http_response_events(store, entries):
281
+ """Seed the store with synthetic http_response events for token_report tests."""
282
+ from chp_core.types import ExecutionEvidence, CorrelationContext, new_id
283
+
284
+ for entry in entries:
285
+ payload = {
286
+ "method": "POST",
287
+ "url": "http://localhost:8092/v1/chat/completions",
288
+ "status_code": 200,
289
+ "content_type": "application/json",
290
+ "body_length": 100,
291
+ "truncated": False,
292
+ "duration_ms": 250,
293
+ }
294
+ if "model" in entry:
295
+ payload.update({
296
+ "prompt_tokens": entry["prompt"],
297
+ "completion_tokens": entry["completion"],
298
+ "total_tokens": entry["prompt"] + entry["completion"],
299
+ "model": entry["model"],
300
+ })
301
+ store.append(ExecutionEvidence(
302
+ event_id=new_id("evt"),
303
+ event_type="http_response",
304
+ invocation_id=new_id("inv"),
305
+ capability_id="chp.adapters.http.request",
306
+ capability_version="1.0.0",
307
+ host_id="test-host",
308
+ correlation=CorrelationContext(),
309
+ outcome="success",
310
+ payload=payload,
311
+ redacted=False,
312
+ ))
313
+
314
+
315
+ class TestTokenReport:
316
+ def _make_audit_with_store(self):
317
+ store = SQLiteEvidenceStore(":memory:")
318
+ audit = AuditAdapter(AuditConfig(store=store))
319
+ audit_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
320
+ register_adapter(audit_host, audit)
321
+ return audit_host, store
322
+
323
+ def test_empty_store_returns_zeros(self):
324
+ host, _ = self._make_audit_with_store()
325
+ r = host.invoke("chp.adapters.audit.token_report", {})
326
+ assert r.outcome == "success"
327
+ assert r.data["sovereign"]["total_tokens"] == 0
328
+ assert r.data["sovereign"]["by_model"] == []
329
+ assert r.data["estimated_frontier_cost_usd"] == 0.0
330
+ assert r.data["backfill"]["calls_without_token_data"] == 0
331
+
332
+ def test_aggregates_by_model(self):
333
+ host, store = self._make_audit_with_store()
334
+ _seed_http_response_events(store, [
335
+ {"model": "fastcontext", "prompt": 100, "completion": 50},
336
+ {"model": "fastcontext", "prompt": 200, "completion": 80},
337
+ {"model": "vllm-qwen", "prompt": 300, "completion": 120},
338
+ ])
339
+ r = host.invoke("chp.adapters.audit.token_report", {})
340
+ assert r.outcome == "success"
341
+ by_model = {m["model"]: m for m in r.data["sovereign"]["by_model"]}
342
+ assert by_model["fastcontext"]["prompt_tokens"] == 300
343
+ assert by_model["fastcontext"]["completion_tokens"] == 130
344
+ assert by_model["fastcontext"]["calls"] == 2
345
+ assert by_model["vllm-qwen"]["calls"] == 1
346
+ assert r.data["sovereign"]["total_prompt_tokens"] == 600
347
+ assert r.data["sovereign"]["total_completion_tokens"] == 250
348
+
349
+ def test_backfill_counts_events_without_tokens(self):
350
+ host, store = self._make_audit_with_store()
351
+ _seed_http_response_events(store, [
352
+ {"model": "fastcontext", "prompt": 50, "completion": 20},
353
+ {}, # no model/tokens — pre-tracking
354
+ {}, # no model/tokens — pre-tracking
355
+ ])
356
+ r = host.invoke("chp.adapters.audit.token_report", {})
357
+ assert r.data["backfill"]["calls_without_token_data"] == 2
358
+
359
+ def test_frontier_cost_calculation(self):
360
+ host, store = self._make_audit_with_store()
361
+ _seed_http_response_events(store, [
362
+ {"model": "fastcontext", "prompt": 1_000_000, "completion": 1_000_000},
363
+ ])
364
+ # Default: $3/1M input, $15/1M output → $3 + $15 = $18
365
+ r = host.invoke("chp.adapters.audit.token_report", {})
366
+ assert r.data["estimated_frontier_cost_usd"] == 18.0
367
+
368
+ def test_custom_pricing(self):
369
+ host, store = self._make_audit_with_store()
370
+ _seed_http_response_events(store, [
371
+ {"model": "fastcontext", "prompt": 1_000_000, "completion": 0},
372
+ ])
373
+ r = host.invoke("chp.adapters.audit.token_report", {
374
+ "frontier_price_per_1m_input": 5.0,
375
+ "frontier_price_per_1m_output": 20.0,
376
+ })
377
+ assert r.data["estimated_frontier_cost_usd"] == 5.0