auditgate 0.2.1__tar.gz → 0.3.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.
Files changed (26) hide show
  1. auditgate-0.3.0/CLAUDE.md +81 -0
  2. {auditgate-0.2.1 → auditgate-0.3.0}/PKG-INFO +1 -1
  3. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/__init__.py +4 -2
  4. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/cli.py +8 -5
  5. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/core.py +5 -6
  6. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/emitter.py +2 -1
  7. auditgate-0.3.0/auditgate/engine.py +385 -0
  8. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/store.py +94 -7
  9. {auditgate-0.2.1 → auditgate-0.3.0}/pyproject.toml +2 -1
  10. auditgate-0.3.0/tests/test_async.py +492 -0
  11. {auditgate-0.2.1 → auditgate-0.3.0}/tests/test_auditgate.py +32 -31
  12. {auditgate-0.2.1 → auditgate-0.3.0}/tests/test_cli.py +24 -23
  13. {auditgate-0.2.1 → auditgate-0.3.0}/tests/test_golden_vectors.py +5 -2
  14. auditgate-0.2.1/auditgate/engine.py +0 -209
  15. {auditgate-0.2.1 → auditgate-0.3.0}/.gitignore +0 -0
  16. {auditgate-0.2.1 → auditgate-0.3.0}/CANONICAL_JSON.md +0 -0
  17. {auditgate-0.2.1 → auditgate-0.3.0}/LICENSE +0 -0
  18. {auditgate-0.2.1 → auditgate-0.3.0}/README.md +0 -0
  19. {auditgate-0.2.1 → auditgate-0.3.0}/SEMANTICS.md +0 -0
  20. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/__main__.py +0 -0
  21. {auditgate-0.2.1 → auditgate-0.3.0}/auditgate/py.typed +0 -0
  22. {auditgate-0.2.1 → auditgate-0.3.0}/examples/basic_audit.py +0 -0
  23. {auditgate-0.2.1 → auditgate-0.3.0}/examples/bench_auditgate.py +0 -0
  24. {auditgate-0.2.1 → auditgate-0.3.0}/examples/compose_with_gates.py +0 -0
  25. {auditgate-0.2.1 → auditgate-0.3.0}/examples/fail_closed_guarantee.py +0 -0
  26. {auditgate-0.2.1 → auditgate-0.3.0}/schemas/auditgate-entry.schema.json +0 -0
@@ -0,0 +1,81 @@
1
+ # CLAUDE.md
2
+
3
+ ## Identity
4
+ AuditGate — tamper-evident audit trail primitive for AI agents.
5
+ Hash-chained logging with severity filtering and retention pruning. Part of the ActionGate governance stack. Apache-2.0. Zero dependencies.
6
+
7
+ ## Stack
8
+ Python 3.12+ · Hatchling · mypy strict · ruff · PyPI: `auditgate` @ `actiongate-oss`
9
+
10
+ ## Layout
11
+ ```
12
+ auditgate/ # core.py, engine.py, emitter.py, store.py, cli.py, __main__.py
13
+ tests/ # pytest suite, test_golden_vectors.py, test_cli.py
14
+ SEMANTICS.md # normative spec (governs over implementation)
15
+ pyproject.toml # package config
16
+ benchmarks/ # reproducible perf tests
17
+ ```
18
+
19
+ ## Commands
20
+ ```bash
21
+ pytest tests/ -v # test
22
+ mypy auditgate/ --strict # typecheck
23
+ ruff check auditgate/ # lint
24
+ python -m build && twine upload dist/* # publish
25
+ git log --oneline -5 && git status # session start check
26
+ ```
27
+
28
+ ## Inviolable Rules
29
+ - NEVER modify SEMANTICS.md without explicit approval
30
+ - NEVER force-push to main
31
+ - Tests pass before every push
32
+
33
+ ## Code Standards
34
+ **Architecture:**
35
+ - Frozen dataclasses with slots: `@dataclass(frozen=True, slots=True)`
36
+ - Enums use `auto()`
37
+ - `_Missing` sentinel pattern with `MISSING` constant and `Result[T]`
38
+ - Engine uses `__slots__`, accepts `store: Store | None`, `clock: Callable`
39
+ - Store is a `Protocol`, MemoryStore uses threading locks
40
+ - Emitter mixin: `on_decision()`, `_emit()`, `listener_errors`
41
+ - Integrity modes: NONE, HASH, CHAIN (tamper-evident)
42
+ - Severity filtering via AuditPolicy
43
+ - Verdict enum: ALLOW, BLOCK, ERROR, OVERRIDE
44
+
45
+ **Style:**
46
+ - Every public symbol has type annotations. No exceptions.
47
+ - No `Any` unless wrapping genuinely dynamic external input
48
+ - Prefer `collections.abc` over `typing` for abstract types
49
+ - Single responsibility: one class, one job. If a docstring needs "and", split it.
50
+ - Functions ≤25 lines. Classes ≤200 lines. Files ≤400 lines. Exceed = refactor.
51
+ - No magic numbers. Named constants or enum members only.
52
+ - Error paths return typed errors, never raise except for programmer bugs
53
+ - Guard clauses at top, happy path unindented. No deep nesting.
54
+
55
+ **Naming:**
56
+ - `snake_case` for functions/variables, `PascalCase` for classes, `UPPER_SNAKE` for constants
57
+ - Boolean vars/params: `is_`, `has_`, `should_`, `can_` prefixes
58
+ - Private: single underscore. No dunder methods unless implementing a protocol.
59
+
60
+ **Testing:**
61
+ - Every public method has at least one test
62
+ - Test names: `test_<method>_<scenario>_<expected>`
63
+ - No mocks unless testing I/O boundaries. Prefer real objects.
64
+ - Edge cases: empty input, boundary values, hash chain verification, retention pruning, CLI output
65
+
66
+ **Performance:**
67
+ - Hot path (record) must be allocation-minimal
68
+ - SHA-256 hashing on hot path is acceptable (required for integrity)
69
+ - Prefer `__slots__` on all frequently instantiated classes
70
+ - Store operations: O(1) lookup, O(n) scan only for admin ops (clear_all)
71
+
72
+ ## Cross-Gate Invariants
73
+ All four gates (actiongate, budgetgate, rulegate, auditgate) share identical patterns:
74
+ - Same Engine API: register, check, enforce, guard, guard_result, clear, clear_all
75
+ - Same Store protocol: get, set, delete, keys
76
+ - Same Emitter: on_decision, _emit, listener_errors
77
+ - Same Decision/Result types with __bool__, to_dict
78
+ - Same pyproject.toml: hatchling, >=3.12, Apache-2.0, same extras pattern
79
+
80
+ ## Commit Convention
81
+ Imperative mood, scoped: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auditgate
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Compliance-grade audit logging for agent systems.
5
5
  Project-URL: Homepage, https://github.com/actiongate-oss/auditgate
6
6
  Project-URL: Documentation, https://github.com/actiongate-oss/auditgate#readme
@@ -25,7 +25,7 @@ from .core import (
25
25
  )
26
26
  from .emitter import Emitter
27
27
  from .engine import AuditError, Engine
28
- from .store import MemoryStore, QueryFilter, Store
28
+ from .store import AsyncMemoryStore, AsyncStore, MemoryStore, QueryFilter, Store
29
29
 
30
30
  __all__ = [
31
31
  "Trail",
@@ -47,8 +47,10 @@ __all__ = [
47
47
  "AuditError",
48
48
  "Emitter",
49
49
  "Store",
50
+ "AsyncStore",
50
51
  "MemoryStore",
52
+ "AsyncMemoryStore",
51
53
  "QueryFilter",
52
54
  ]
53
55
 
54
- __version__ = "0.1.1"
56
+ __version__ = "0.3.0"
@@ -19,11 +19,12 @@ import argparse
19
19
  import json
20
20
  import sys
21
21
  from pathlib import Path
22
+ from typing import Any
22
23
 
23
24
  from .core import compute_hash
24
25
 
25
26
 
26
- def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[str]]:
27
+ def _verify_log(entries: list[dict[str, Any]], verbose: bool = False) -> tuple[bool, list[str]]:
27
28
  """Verify a list of serialized audit entries.
28
29
 
29
30
  Works with raw dicts from JSON (no AuditEntry objects needed).
@@ -35,7 +36,7 @@ def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[
35
36
  return True, ["Empty log — nothing to verify."]
36
37
 
37
38
  # Group by trail
38
- trails: dict[str, list[dict]] = {}
39
+ trails: dict[str, list[dict[str, Any]]] = {}
39
40
  for entry in entries:
40
41
  trail = entry.get("trail", "<unknown>")
41
42
  trails.setdefault(trail, []).append(entry)
@@ -78,8 +79,10 @@ def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[
78
79
  prev_entry_hash = prev_entry.get("entry_hash")
79
80
  if prev_entry_hash != prev_hash:
80
81
  all_valid = False
82
+ prev_seq = prev_entry.get('sequence', '?')
81
83
  messages.append(
82
- f" [{seq}] FAIL — chain break (prev_hash does not match entry [{prev_entry.get('sequence', '?')}])"
84
+ f" [{seq}] FAIL — chain break"
85
+ f" (prev_hash does not match entry [{prev_seq}])"
83
86
  )
84
87
  if verbose:
85
88
  messages.append(f" expected prev: {prev_entry_hash}")
@@ -99,7 +102,7 @@ def _verify_log(entries: list[dict], verbose: bool = False) -> tuple[bool, list[
99
102
  messages.append(f" [{seq}] OK — {verdict} hash:{entry_hash[:12]}...")
100
103
 
101
104
  if all_valid:
102
- messages.append(f" VALID — all hashes verified")
105
+ messages.append(" VALID — all hashes verified")
103
106
 
104
107
  return all_valid, messages
105
108
 
@@ -145,7 +148,7 @@ def _cmd_verify(args: argparse.Namespace) -> int:
145
148
  if valid:
146
149
  print(f"RESULT: VALID ({len(entries)} entries verified)")
147
150
  else:
148
- print(f"RESULT: INVALID (chain integrity broken)")
151
+ print("RESULT: INVALID (chain integrity broken)")
149
152
 
150
153
  return 0 if valid else 1
151
154
 
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  import hashlib
12
12
  import json
13
13
  from dataclasses import dataclass, field
14
- from datetime import datetime, timezone
14
+ from datetime import UTC, datetime
15
15
  from enum import Enum, auto
16
16
  from typing import Any
17
17
 
@@ -170,12 +170,12 @@ class Result[T]:
170
170
  raise RuntimeError(
171
171
  f"unwrap() called on dropped audit: {self.decision.reason}"
172
172
  )
173
- return self._value # type: ignore[return-value]
173
+ return self._value
174
174
 
175
175
  def unwrap_or(self, default: T) -> T:
176
176
  if isinstance(self._value, _Missing):
177
177
  return default
178
- return self._value # type: ignore[return-value]
178
+ return self._value
179
179
 
180
180
 
181
181
  # ── Integrity ──
@@ -224,8 +224,7 @@ def verify_chain(entries: list[AuditEntry]) -> tuple[bool, int | None]:
224
224
  if entry.entry_hash != expected:
225
225
  return False, entry.sequence
226
226
 
227
- if entry.prev_hash is not None and i > 0:
228
- if entries[i - 1].entry_hash != entry.prev_hash:
227
+ if entry.prev_hash is not None and i > 0 and entries[i - 1].entry_hash != entry.prev_hash:
229
228
  return False, entry.sequence
230
229
 
231
230
  return True, None
@@ -233,4 +232,4 @@ def verify_chain(entries: list[AuditEntry]) -> tuple[bool, int | None]:
233
232
 
234
233
  def wall_clock() -> str:
235
234
  """Current wall-clock time as ISO 8601 UTC."""
236
- return datetime.now(timezone.utc).isoformat()
235
+ return datetime.now(UTC).isoformat()
@@ -8,7 +8,8 @@
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from typing import Any, Callable
11
+ from collections.abc import Callable
12
+ from typing import Any
12
13
 
13
14
 
14
15
  class Emitter:
@@ -0,0 +1,385 @@
1
+ # Copyright 2026 actiongate-oss
2
+ # Licensed under the Apache License, Version 2.0;
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License in the LICENSE file at the
5
+ # root of this repository.
6
+
7
+ """AuditGate engine for compliance-grade audit logging."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import time
13
+ from collections.abc import Callable, Coroutine
14
+ from functools import wraps
15
+ from typing import Any, ParamSpec, TypeVar
16
+
17
+ from .core import (
18
+ AuditEntry,
19
+ AuditPolicy,
20
+ Decision,
21
+ IntegrityMode,
22
+ Mode,
23
+ Result,
24
+ Severity,
25
+ Status,
26
+ StoreErrorMode,
27
+ Trail,
28
+ Verdict,
29
+ compute_hash,
30
+ wall_clock,
31
+ )
32
+ from .emitter import Emitter
33
+ from .store import AsyncMemoryStore, AsyncStore, MemoryStore, Store
34
+
35
+ P = ParamSpec("P")
36
+ T = TypeVar("T")
37
+
38
+
39
+ class AuditError(RuntimeError):
40
+ """Raised when audit logging fails in HARD mode."""
41
+ def __init__(self, decision: Decision) -> None:
42
+ super().__init__(decision.reason or f"Audit failure: {decision.status}")
43
+ self.decision = decision
44
+
45
+
46
+ class Engine:
47
+ """AuditGate engine for compliance-grade audit logging."""
48
+
49
+ __slots__ = (
50
+ "_store", "_async_store", "_clock", "_wall_clock", "_recorded_by",
51
+ "_policies", "_sequences", "_emitter",
52
+ )
53
+
54
+ def __init__(
55
+ self,
56
+ store: Store | None = None,
57
+ clock: Callable[[], float] | None = None,
58
+ wall_clock_fn: Callable[[], str] | None = None,
59
+ recorded_by: str = "",
60
+ emitter: Emitter | None = None,
61
+ async_store: AsyncStore | None = None,
62
+ ) -> None:
63
+ self._store: Store = store or MemoryStore()
64
+ self._async_store: AsyncStore = async_store or AsyncMemoryStore()
65
+ self._clock = clock or time.monotonic
66
+ self._wall_clock = wall_clock_fn or wall_clock
67
+ self._recorded_by = recorded_by
68
+ self._policies: dict[Trail, AuditPolicy] = {}
69
+ self._sequences: dict[Trail, int] = {}
70
+ self._emitter = emitter or Emitter()
71
+
72
+ def register(self, trail: Trail, policy: AuditPolicy) -> None:
73
+ self._policies[trail] = policy
74
+
75
+ def policy_for(self, trail: Trail) -> AuditPolicy | None:
76
+ return self._policies.get(trail)
77
+
78
+ def on_decision(self, listener: Callable[[Decision], None]) -> None:
79
+ self._emitter.add(listener)
80
+
81
+ @property
82
+ def listener_errors(self) -> int:
83
+ return self._emitter.error_count
84
+
85
+ def record(
86
+ self,
87
+ trail: Trail,
88
+ *,
89
+ verdict: Verdict,
90
+ severity: Severity,
91
+ gate_type: str,
92
+ gate_identity: str,
93
+ reason: str | None = None,
94
+ detail: dict[str, Any] | None = None,
95
+ policy: AuditPolicy | None = None,
96
+ ) -> Decision:
97
+ """Record an audit entry for a gate decision."""
98
+ if policy is not None:
99
+ self._policies[trail] = policy
100
+ p = self._policies.get(trail)
101
+ if p is None:
102
+ p = AuditPolicy()
103
+ self._policies[trail] = p
104
+ if _sev_rank(severity) < _sev_rank(p.min_severity):
105
+ return self._decide(trail, p, Status.DROPPED,
106
+ reason=f"Below min_severity ({p.min_severity.name})")
107
+ now = self._clock()
108
+ try:
109
+ entry = self._build_entry(trail, p, now, verdict, severity,
110
+ gate_type, gate_identity, reason, detail or {})
111
+ except Exception as exc:
112
+ return self._on_store_error(trail, p, f"Entry build failed: {exc}")
113
+ try:
114
+ self._store.append(entry)
115
+ except Exception as exc:
116
+ return self._on_store_error(trail, p, f"Store append failed: {exc}")
117
+ if p.retention_seconds is not None:
118
+ with contextlib.suppress(Exception):
119
+ self._store.prune(trail, now - p.retention_seconds)
120
+ return self._decide(trail, p, Status.RECORDED, entry=entry)
121
+
122
+ def enforce(self, decision: Decision) -> None:
123
+ if decision.dropped and decision.policy.mode == Mode.HARD:
124
+ raise AuditError(decision)
125
+
126
+ def clear(self, trail: Trail) -> None:
127
+ self._store.clear(trail)
128
+ self._sequences.pop(trail, None)
129
+
130
+ def clear_all(self) -> None:
131
+ self._store.clear_all()
132
+ self._sequences.clear()
133
+
134
+ def guard(
135
+ self, trail: Trail, *, policy: AuditPolicy | None = None,
136
+ verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
137
+ gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
138
+ ) -> Callable[[Callable[P, T]], Callable[P, T]]:
139
+ if policy is not None:
140
+ self.register(trail, policy)
141
+ def decorator(fn: Callable[P, T]) -> Callable[P, T]:
142
+ @wraps(fn)
143
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
144
+ d = self._guard_record(trail, verdict, severity, gate_type, fn, meta)
145
+ if d.dropped:
146
+ self.enforce(d)
147
+ return fn(*args, **kwargs)
148
+ return wrapper
149
+ return decorator
150
+
151
+ def guard_result(
152
+ self, trail: Trail, *, policy: AuditPolicy | None = None,
153
+ verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
154
+ gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
155
+ ) -> Callable[[Callable[P, T]], Callable[P, Result[T]]]:
156
+ if policy is not None:
157
+ self.register(trail, policy)
158
+ def decorator(fn: Callable[P, T]) -> Callable[P, Result[T]]:
159
+ @wraps(fn)
160
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T]:
161
+ d = self._guard_record(trail, verdict, severity, gate_type, fn, meta,
162
+ prefix="guard_result")
163
+ if d.dropped:
164
+ return Result(decision=d)
165
+ return Result(decision=d, _value=fn(*args, **kwargs))
166
+ return wrapper
167
+ return decorator
168
+
169
+ # ─────────────────────────────────────────────────────────────
170
+ # Async API
171
+ # ─────────────────────────────────────────────────────────────
172
+
173
+ async def async_record(
174
+ self,
175
+ trail: Trail,
176
+ *,
177
+ verdict: Verdict,
178
+ severity: Severity,
179
+ gate_type: str,
180
+ gate_identity: str,
181
+ reason: str | None = None,
182
+ detail: dict[str, Any] | None = None,
183
+ policy: AuditPolicy | None = None,
184
+ ) -> Decision:
185
+ """Async version of record(). Uses async_store."""
186
+ if policy is not None:
187
+ self._policies[trail] = policy
188
+ p = self._policies.get(trail)
189
+ if p is None:
190
+ p = AuditPolicy()
191
+ self._policies[trail] = p
192
+ if _sev_rank(severity) < _sev_rank(p.min_severity):
193
+ return self._decide(trail, p, Status.DROPPED,
194
+ reason=f"Below min_severity ({p.min_severity.name})")
195
+ now = self._clock()
196
+ try:
197
+ entry = await self._async_build_entry(
198
+ trail, p, now, verdict, severity,
199
+ gate_type, gate_identity, reason, detail or {},
200
+ )
201
+ except Exception as exc:
202
+ return self._on_store_error(trail, p, f"Entry build failed: {exc}")
203
+ try:
204
+ await self._async_store.append(entry)
205
+ except Exception as exc:
206
+ return self._on_store_error(trail, p, f"Store append failed: {exc}")
207
+ if p.retention_seconds is not None:
208
+ with contextlib.suppress(Exception):
209
+ await self._async_store.prune(trail, now - p.retention_seconds)
210
+ return self._decide(trail, p, Status.RECORDED, entry=entry)
211
+
212
+ async def async_enforce(self, decision: Decision) -> None:
213
+ """Async version of enforce(). Raises AuditError in HARD mode."""
214
+ if decision.dropped and decision.policy.mode == Mode.HARD:
215
+ raise AuditError(decision)
216
+
217
+ def async_guard(
218
+ self, trail: Trail, *, policy: AuditPolicy | None = None,
219
+ verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
220
+ gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
221
+ ) -> Callable[
222
+ [Callable[P, Coroutine[object, object, T]]],
223
+ Callable[P, Coroutine[object, object, T]],
224
+ ]:
225
+ """Async decorator that raises AuditError on dropped + HARD."""
226
+ if policy is not None:
227
+ self.register(trail, policy)
228
+
229
+ def decorator(
230
+ fn: Callable[P, Coroutine[object, object, T]],
231
+ ) -> Callable[P, Coroutine[object, object, T]]:
232
+ @wraps(fn)
233
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
234
+ d = await self._async_guard_record(
235
+ trail, verdict, severity, gate_type, fn, meta,
236
+ )
237
+ if d.dropped:
238
+ await self.async_enforce(d)
239
+ return await fn(*args, **kwargs)
240
+ return wrapper
241
+ return decorator
242
+
243
+ def async_guard_result(
244
+ self, trail: Trail, *, policy: AuditPolicy | None = None,
245
+ verdict: Verdict = Verdict.ALLOW, severity: Severity = Severity.INFO,
246
+ gate_type: str = "auditgate", meta: dict[str, Any] | None = None,
247
+ ) -> Callable[
248
+ [Callable[P, Coroutine[object, object, T]]],
249
+ Callable[P, Coroutine[object, object, Result[T]]],
250
+ ]:
251
+ """Async decorator that returns Result[T] (never raises)."""
252
+ if policy is not None:
253
+ self.register(trail, policy)
254
+
255
+ def decorator(
256
+ fn: Callable[P, Coroutine[object, object, T]],
257
+ ) -> Callable[P, Coroutine[object, object, Result[T]]]:
258
+ @wraps(fn)
259
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[T]:
260
+ d = await self._async_guard_record(
261
+ trail, verdict, severity, gate_type, fn, meta,
262
+ prefix="async_guard_result",
263
+ )
264
+ if d.dropped:
265
+ return Result(decision=d)
266
+ return Result(decision=d, _value=await fn(*args, **kwargs))
267
+ return wrapper
268
+ return decorator
269
+
270
+ async def _async_guard_record(
271
+ self, trail: Trail, verdict: Verdict, severity: Severity,
272
+ gate_type: str, fn: Any, meta: dict[str, Any] | None,
273
+ prefix: str = "async_guard",
274
+ ) -> Decision:
275
+ return await self.async_record(
276
+ trail=trail, verdict=verdict, severity=severity,
277
+ gate_type=gate_type, gate_identity=str(trail),
278
+ reason=f"{prefix}:{fn.__qualname__}", detail=meta or {},
279
+ )
280
+
281
+ async def _async_build_entry(
282
+ self, trail: Trail, policy: AuditPolicy, now: float,
283
+ verdict: Verdict, severity: Severity, gate_type: str,
284
+ gate_identity: str, reason: str | None, detail: dict[str, Any],
285
+ ) -> AuditEntry:
286
+ seq = await self._async_next_seq(trail)
287
+ base = {
288
+ "trail": str(trail), "ts": now, "verdict": verdict.name,
289
+ "severity": severity.name, "gate_type": gate_type,
290
+ "gate_identity": gate_identity, "reason": reason,
291
+ "detail": detail, "sequence": seq,
292
+ }
293
+ entry_hash = None
294
+ prev_hash = None
295
+ if policy.integrity == IntegrityMode.HASH:
296
+ entry_hash = compute_hash(base)
297
+ elif policy.integrity == IntegrityMode.CHAIN:
298
+ prev = await self._async_store.last_entry(trail)
299
+ prev_hash = prev.entry_hash if prev is not None else None
300
+ entry_hash = compute_hash(base, prev_hash=prev_hash)
301
+ return AuditEntry(
302
+ trail=trail, ts=now, wall_ts=self._wall_clock(),
303
+ verdict=verdict, severity=severity, gate_type=gate_type,
304
+ gate_identity=gate_identity, recorded_by=self._recorded_by,
305
+ reason=reason, detail=detail, entry_hash=entry_hash,
306
+ prev_hash=prev_hash, sequence=seq,
307
+ )
308
+
309
+ async def _async_next_seq(self, trail: Trail) -> int:
310
+ if trail not in self._sequences:
311
+ last = await self._async_store.last_entry(trail)
312
+ self._sequences[trail] = (last.sequence + 1) if last is not None else 0
313
+ seq = self._sequences[trail]
314
+ self._sequences[trail] = seq + 1
315
+ return seq
316
+
317
+ # ─────────────────────────────────────────────────────────────
318
+ # Internal (sync)
319
+ # ─────────────────────────────────────────────────────────────
320
+
321
+ def _guard_record(
322
+ self, trail: Trail, verdict: Verdict, severity: Severity,
323
+ gate_type: str, fn: Any, meta: dict[str, Any] | None,
324
+ prefix: str = "guard",
325
+ ) -> Decision:
326
+ return self.record(trail=trail, verdict=verdict, severity=severity,
327
+ gate_type=gate_type, gate_identity=str(trail),
328
+ reason=f"{prefix}:{fn.__qualname__}", detail=meta or {})
329
+
330
+ def _build_entry(
331
+ self, trail: Trail, policy: AuditPolicy, now: float,
332
+ verdict: Verdict, severity: Severity, gate_type: str,
333
+ gate_identity: str, reason: str | None, detail: dict[str, Any],
334
+ ) -> AuditEntry:
335
+ seq = self._next_seq(trail)
336
+ base = {
337
+ "trail": str(trail), "ts": now, "verdict": verdict.name,
338
+ "severity": severity.name, "gate_type": gate_type,
339
+ "gate_identity": gate_identity, "reason": reason,
340
+ "detail": detail, "sequence": seq,
341
+ }
342
+ entry_hash = None
343
+ prev_hash = None
344
+ if policy.integrity == IntegrityMode.HASH:
345
+ entry_hash = compute_hash(base)
346
+ elif policy.integrity == IntegrityMode.CHAIN:
347
+ prev = self._store.last_entry(trail)
348
+ prev_hash = prev.entry_hash if prev is not None else None
349
+ entry_hash = compute_hash(base, prev_hash=prev_hash)
350
+ return AuditEntry(
351
+ trail=trail, ts=now, wall_ts=self._wall_clock(),
352
+ verdict=verdict, severity=severity, gate_type=gate_type,
353
+ gate_identity=gate_identity, recorded_by=self._recorded_by,
354
+ reason=reason, detail=detail, entry_hash=entry_hash,
355
+ prev_hash=prev_hash, sequence=seq,
356
+ )
357
+
358
+ def _next_seq(self, trail: Trail) -> int:
359
+ if trail not in self._sequences:
360
+ last = self._store.last_entry(trail)
361
+ self._sequences[trail] = (last.sequence + 1) if last is not None else 0
362
+ seq = self._sequences[trail]
363
+ self._sequences[trail] = seq + 1
364
+ return seq
365
+
366
+ def _on_store_error(
367
+ self, trail: Trail, policy: AuditPolicy, msg: str,
368
+ ) -> Decision:
369
+ decision = self._decide(trail, policy, Status.DROPPED, reason=msg)
370
+ if policy.on_store_error == StoreErrorMode.FAIL_CLOSED and policy.mode == Mode.HARD:
371
+ raise AuditError(decision)
372
+ return decision
373
+
374
+ def _decide(
375
+ self, trail: Trail, policy: AuditPolicy, status: Status,
376
+ entry: AuditEntry | None = None, reason: str | None = None,
377
+ ) -> Decision:
378
+ d = Decision(status=status, trail=trail, policy=policy, entry=entry, reason=reason)
379
+ self._emitter.emit(d)
380
+ return d
381
+
382
+
383
+ def _sev_rank(severity: Severity) -> int:
384
+ return {Severity.DEBUG: 0, Severity.INFO: 1, Severity.WARN: 2,
385
+ Severity.ERROR: 3, Severity.CRITICAL: 4}[severity]