surf-agentic-base 0.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 (45) hide show
  1. agentic_base/__init__.py +0 -0
  2. agentic_base/client.py +172 -0
  3. agentic_base/code_policy/__init__.py +0 -0
  4. agentic_base/code_policy/policy.py +122 -0
  5. agentic_base/config.py +24 -0
  6. agentic_base/db.py +42 -0
  7. agentic_base/domain/__init__.py +1 -0
  8. agentic_base/domain/epochs.py +187 -0
  9. agentic_base/domain/integrity.py +135 -0
  10. agentic_base/domain/outcomes.py +260 -0
  11. agentic_base/domain/run_record.py +190 -0
  12. agentic_base/domain/validity.py +254 -0
  13. agentic_base/hpc/__init__.py +0 -0
  14. agentic_base/hpc/clusters.py +165 -0
  15. agentic_base/hpc/job_result.py +129 -0
  16. agentic_base/hpc/profiles/lumi.yaml +74 -0
  17. agentic_base/hpc/profiles/snellius.yaml +73 -0
  18. agentic_base/limits.py +56 -0
  19. agentic_base/llm/__init__.py +0 -0
  20. agentic_base/llm/health.py +136 -0
  21. agentic_base/llm/resilience.py +87 -0
  22. agentic_base/main.py +66 -0
  23. agentic_base/mcp/__init__.py +0 -0
  24. agentic_base/mcp/server.py +266 -0
  25. agentic_base/observability/__init__.py +0 -0
  26. agentic_base/observability/conventions.py +183 -0
  27. agentic_base/observability/tracing.py +109 -0
  28. agentic_base/provenance/__init__.py +25 -0
  29. agentic_base/provenance/emit.py +272 -0
  30. agentic_base/py.typed +0 -0
  31. agentic_base/recording.py +131 -0
  32. agentic_base/routers/__init__.py +0 -0
  33. agentic_base/routers/health.py +72 -0
  34. agentic_base/routers/runs.py +157 -0
  35. agentic_base/security/__init__.py +0 -0
  36. agentic_base/security/netsec.py +258 -0
  37. agentic_base/tools/__init__.py +0 -0
  38. agentic_base/tools/types.py +139 -0
  39. agentic_base/utils/__init__.py +1 -0
  40. agentic_base/utils/logging.py +203 -0
  41. surf_agentic_base-0.3.0.dist-info/METADATA +144 -0
  42. surf_agentic_base-0.3.0.dist-info/RECORD +45 -0
  43. surf_agentic_base-0.3.0.dist-info/WHEEL +5 -0
  44. surf_agentic_base-0.3.0.dist-info/licenses/LICENSE +190 -0
  45. surf_agentic_base-0.3.0.dist-info/top_level.txt +1 -0
File without changes
agentic_base/client.py ADDED
@@ -0,0 +1,172 @@
1
+ """Recording a run from someone else's agent.
2
+
3
+ The point of this file is that recording has to be easier than not recording, because whoever is
4
+ trying to get one thing working will otherwise wrap the HTTP API themselves, and their wrapper
5
+ becomes the real interface.
6
+
7
+ Two arguments have no default, and that is deliberate. See `RunRecordCreate` for the corpus that
8
+ resulted from making provenance optional.
9
+
10
+ Usage:
11
+
12
+ recorder = RunRecorder("https://agentic-base.example.org", tenant="hpml",
13
+ code_revision=git_sha())
14
+
15
+ with recorder.run(item="issue-4312", arm="baseline") as run:
16
+ answer = my_agent(task)
17
+ run.messages = transcript
18
+ run.model = "some-model"
19
+
20
+ recorder.label(run.run_id, resolved=True, label_source=LabelSource.OFFICIAL_HARNESS,
21
+ instrument="harness-1.4")
22
+
23
+ The context manager records on exit, including when the body raises, because a run that crashed
24
+ is a measurement and losing it biases whatever it was part of.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import subprocess
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from types import TracebackType
33
+ from typing import Any, Literal
34
+
35
+ import httpx
36
+
37
+ from agentic_base.domain.outcomes import LabelSource, RunStatus
38
+
39
+
40
+ def git_revision(path: str = ".") -> str:
41
+ """The current commit, or an empty string if this is not a checkout.
42
+
43
+ Returns empty rather than raising so a caller can decide. An empty revision will be refused
44
+ by the service, which is the intended outcome: a run that cannot say which code produced it
45
+ cannot be placed against a later change in what a field means.
46
+ """
47
+ try:
48
+ out = subprocess.run(
49
+ ["git", "-C", path, "rev-parse", "HEAD"],
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=5,
53
+ check=False,
54
+ )
55
+ except (OSError, subprocess.SubprocessError):
56
+ return ""
57
+ return out.stdout.strip() if out.returncode == 0 else ""
58
+
59
+
60
+ @dataclass
61
+ class PendingRun:
62
+ """A run in progress. Fill in what you know; it is recorded when the block exits."""
63
+
64
+ item: str = ""
65
+ arm: str = ""
66
+ arm_fingerprint: str = ""
67
+ system_prompt: str = ""
68
+ messages: list[dict[str, Any]] = field(default_factory=list)
69
+ model: str = ""
70
+ endpoint: str = ""
71
+ precision: str = ""
72
+ status: RunStatus = RunStatus.COMPLETED
73
+ failure_kind: str = ""
74
+ prompt_tokens: int = 0
75
+ completion_tokens: int = 0
76
+ joules: float = 0.0
77
+ num_steps: int = 0
78
+ total_tool_calls: int = 0
79
+ extra: dict[str, Any] = field(default_factory=dict)
80
+ run_id: str = ""
81
+ elapsed_ms: float = 0.0
82
+
83
+
84
+ class RunRecorder:
85
+ """Records runs against a platform instance."""
86
+
87
+ def __init__(
88
+ self,
89
+ base_url: str,
90
+ tenant: str,
91
+ code_revision: str,
92
+ *,
93
+ timeout_s: float = 10.0,
94
+ ) -> None:
95
+ if not tenant:
96
+ raise ValueError("tenant is required")
97
+ if not code_revision:
98
+ raise ValueError(
99
+ "code_revision is required. Use git_revision(), or pass the build identifier of "
100
+ "whatever produced this run. A run that cannot name its code cannot be placed "
101
+ "against a later change in what a field means."
102
+ )
103
+ self.base_url = base_url.rstrip("/")
104
+ self.tenant = tenant
105
+ self.code_revision = code_revision
106
+ self._timeout = timeout_s
107
+
108
+ def run(self, **kwargs: Any) -> _RunContext:
109
+ """Open a run. Records on exit, including on an exception."""
110
+ return _RunContext(self, PendingRun(**kwargs))
111
+
112
+ def record(self, pending: PendingRun) -> str:
113
+ payload = {
114
+ "tenant": self.tenant,
115
+ "code_revision": self.code_revision,
116
+ **{
117
+ key: (value.value if hasattr(value, "value") else value)
118
+ for key, value in vars(pending).items()
119
+ if key not in ("run_id",)
120
+ },
121
+ }
122
+ response = httpx.post(
123
+ f"{self.base_url}/runs", json=payload, timeout=self._timeout
124
+ )
125
+ response.raise_for_status()
126
+ return response.json()["run_id"]
127
+
128
+ def label(
129
+ self,
130
+ run_id: str,
131
+ *,
132
+ resolved: bool,
133
+ label_source: LabelSource,
134
+ instrument: str = "",
135
+ degraded: bool = False,
136
+ ) -> None:
137
+ """Attach an outcome. The scorer is a required argument and has no default."""
138
+ response = httpx.post(
139
+ f"{self.base_url}/runs/{run_id}/label",
140
+ json={
141
+ "resolved": resolved,
142
+ "label_source": label_source.value,
143
+ "instrument": instrument,
144
+ "degraded": degraded,
145
+ },
146
+ timeout=self._timeout,
147
+ )
148
+ response.raise_for_status()
149
+
150
+
151
+ class _RunContext:
152
+ def __init__(self, recorder: RunRecorder, pending: PendingRun) -> None:
153
+ self._recorder = recorder
154
+ self._pending = pending
155
+ self._started = 0.0
156
+
157
+ def __enter__(self) -> PendingRun:
158
+ self._started = time.monotonic()
159
+ return self._pending
160
+
161
+ def __exit__(
162
+ self,
163
+ exc_type: type[BaseException] | None,
164
+ exc: BaseException | None,
165
+ tb: TracebackType | None,
166
+ ) -> Literal[False]:
167
+ self._pending.elapsed_ms = (time.monotonic() - self._started) * 1000
168
+ if exc_type is not None and self._pending.status is RunStatus.COMPLETED:
169
+ self._pending.status = RunStatus.FAILED
170
+ self._pending.failure_kind = exc_type.__name__
171
+ self._pending.run_id = self._recorder.record(self._pending)
172
+ return False
File without changes
@@ -0,0 +1,122 @@
1
+ """A cheap structural pre-filter for model-generated Python.
2
+
3
+ Read this before relying on it.
4
+
5
+ This is not an isolation boundary and must never be described as one. It bounds accidental
6
+ damage and rejects the known reflection-escape class before code reaches an executor. Real
7
+ isolation is the executor's job: a container with a read-only root filesystem and no bind
8
+ mounts, or a sandboxed runtime such as gVisor, or a microVM. On Kubernetes those exist and
9
+ should be used. This file is what runs in front of them, for free, to catch the obvious cases
10
+ without paying for a process.
11
+
12
+ The check is on the parsed syntax tree rather than on the text. A blocklist of patterns over
13
+ source text was verifiably escaped in the predecessor project by writing the attribute name as
14
+ two concatenated string literals, then walking the object graph to reach the file builtin. A
15
+ structural walk sees the same code as the interpreter does, so that trick has nothing to hide
16
+ behind.
17
+
18
+ One route the tree does not show, and which is handled separately: format-string fields resolve
19
+ attributes at runtime, so a dunder inside a field of a literal string is attribute access that
20
+ never appears as an attribute node.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ from dataclasses import dataclass
27
+
28
+ FORBIDDEN_NAMES = frozenset(
29
+ {
30
+ "getattr",
31
+ "setattr",
32
+ "delattr",
33
+ "vars",
34
+ "globals",
35
+ "locals",
36
+ "eval",
37
+ "exec",
38
+ "compile",
39
+ "open",
40
+ "__import__",
41
+ "breakpoint",
42
+ "input",
43
+ "object",
44
+ "super",
45
+ "memoryview",
46
+ "exit",
47
+ "quit",
48
+ }
49
+ )
50
+ """Builtins that hand back the object graph or the import system."""
51
+
52
+
53
+ def is_dunder(name: str) -> bool:
54
+ return name.startswith("__") and name.endswith("__")
55
+
56
+
57
+ def format_field_reaches_dunder(text: str) -> bool:
58
+ """Whether a format field inside a literal string resolves a dunder attribute.
59
+
60
+ `"{0.__class__}".format(1)` performs attribute access that no attribute node describes.
61
+ Only the fields are inspected, so prose mentioning a dunder is untouched.
62
+ """
63
+ i = 0
64
+ while True:
65
+ i = text.find("{", i)
66
+ if i == -1:
67
+ return False
68
+ j = text.find("}", i + 1)
69
+ if j == -1:
70
+ return False
71
+ field = text[i + 1 : j]
72
+ for sep in ("[", "]", "!", ":"):
73
+ field = field.replace(sep, ".")
74
+ if any(is_dunder(part.strip()) for part in field.split(".")):
75
+ return True
76
+ i = j + 1
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Violation:
81
+ kind: str
82
+ detail: str
83
+
84
+ def __str__(self) -> str:
85
+ return f"{self.kind}: {self.detail}"
86
+
87
+
88
+ def inspect(code: str) -> list[Violation]:
89
+ """Structural violations in the given source.
90
+
91
+ Code that does not parse is a violation rather than a pass. Unparseable input must not reach
92
+ an executor, and reporting it as clean would be the same defect as a probe whose failure
93
+ returns zero.
94
+ """
95
+ try:
96
+ tree = ast.parse(code)
97
+ except SyntaxError as exc:
98
+ return [Violation("syntax", f"code does not parse ({exc.msg})")]
99
+
100
+ found: list[Violation] = []
101
+ for node in ast.walk(tree):
102
+ if isinstance(node, ast.Import | ast.ImportFrom):
103
+ found.append(Violation("import", "import statement"))
104
+ elif isinstance(node, ast.Attribute) and is_dunder(node.attr):
105
+ found.append(
106
+ Violation("reflection", f"dunder attribute access ({node.attr})")
107
+ )
108
+ elif isinstance(node, ast.Name) and node.id in FORBIDDEN_NAMES:
109
+ found.append(Violation("builtin", f"forbidden name ({node.id})"))
110
+ elif isinstance(node, ast.Constant) and isinstance(node.value, str):
111
+ if is_dunder(node.value):
112
+ found.append(
113
+ Violation("reflection", f"dunder string literal ({node.value})")
114
+ )
115
+ elif format_field_reaches_dunder(node.value):
116
+ found.append(Violation("reflection", "dunder inside a format field"))
117
+ return found
118
+
119
+
120
+ def is_acceptable(code: str) -> bool:
121
+ """Whether the code passes the pre-filter. Passing is not a guarantee of safety."""
122
+ return not inspect(code)
agentic_base/config.py ADDED
@@ -0,0 +1,24 @@
1
+ """Application settings related files."""
2
+
3
+ from functools import cache
4
+
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """Application settings for this service."""
10
+
11
+ log_level: str = "INFO"
12
+ log_json_format: bool = False
13
+ log_plain_traceback: bool = False
14
+ project_name: str = "Agentic Platform"
15
+ metrics_port: int = 9000
16
+ database_url: str = "sqlite:///./agentic-base.db"
17
+ """PostgreSQL in every deployed environment; SQLite locally so the service runs with no infrastructure."""
18
+
19
+
20
+ @cache
21
+ def get_settings() -> Settings:
22
+ """Retrieve the application settings."""
23
+
24
+ return Settings()
agentic_base/db.py ADDED
@@ -0,0 +1,42 @@
1
+ """Database wiring.
2
+
3
+ SQLModel over PostgreSQL in every deployed environment, which the platform provides as a
4
+ tenant resource. SQLite is the local-development and test default so the service can be run
5
+ and tested without infrastructure.
6
+ """
7
+
8
+ from collections.abc import Iterator
9
+ from functools import cache
10
+
11
+ from sqlmodel import Session, SQLModel, create_engine
12
+
13
+ from agentic_base.config import get_settings
14
+
15
+
16
+ @cache
17
+ def get_engine(): # noqa: ANN201 - engine type is a SQLAlchemy internal
18
+ """Create the process-wide engine."""
19
+ settings = get_settings()
20
+ connect_args = (
21
+ {"check_same_thread": False}
22
+ if settings.database_url.startswith("sqlite")
23
+ else {}
24
+ )
25
+ return create_engine(settings.database_url, connect_args=connect_args)
26
+
27
+
28
+ def init_db() -> None:
29
+ """Create tables that do not exist yet.
30
+
31
+ Schema evolution in a deployed environment is Alembic's job. This exists so that local
32
+ runs and tests do not need a migration step.
33
+ """
34
+ import agentic_base.domain.run_record # noqa: F401 - registers the table
35
+
36
+ SQLModel.metadata.create_all(get_engine())
37
+
38
+
39
+ def get_session() -> Iterator[Session]:
40
+ """FastAPI dependency yielding a session."""
41
+ with Session(get_engine()) as session:
42
+ yield session
@@ -0,0 +1 @@
1
+ """Domain models. The core carries mechanism and no application vocabulary."""
@@ -0,0 +1,187 @@
1
+ """Declaring that a commit changed what a field means.
2
+
3
+ A configuration fingerprint tells you two runs were configured the same way. It cannot tell you
4
+ that the code underneath that configuration changed meaning between them, and that is where the
5
+ expensive mistakes live. In the predecessor project every serious wound was a code change landing
6
+ in the middle of a corpus: an arm that ran with the mechanism its own label said was disabled, a
7
+ retrieval default flipping so that the same absent setting meant opposite things on either side,
8
+ a benchmark whose scoring changed under a stable name.
9
+
10
+ What saved or damaged us each time was whether someone had declared the boundary. So this is a
11
+ small table of declarations, and a classifier that puts a record before a boundary, after it, or
12
+ in the honest third state.
13
+
14
+ The third state matters more than the other two. A record whose code revision is unknown cannot
15
+ be placed, and guessing is how a corpus quietly mixes two things. Pooling across an unknown is
16
+ refused, not estimated.
17
+
18
+ This costs almost nothing today and cannot be added later, because a boundary cannot be declared
19
+ for records that never recorded which code produced them.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import enum
25
+ from collections.abc import Iterable, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import datetime, timezone
28
+
29
+ from pydantic import BaseModel, Field
30
+
31
+
32
+ class Epoch(str, enum.Enum):
33
+ BEFORE = "before"
34
+ AFTER = "after"
35
+ UNKNOWN = "unknown"
36
+ """The record cannot be placed. Not an error, and not poolable."""
37
+
38
+
39
+ class MeaningChange(BaseModel):
40
+ """One declaration that a commit changed what something means.
41
+
42
+ A plain model rather than a table. Applying a boundary is what every consumer needs;
43
+ storing one is what this service happens to do, and a consumer that imports the classifier
44
+ must not thereby acquire a database driver.
45
+ """
46
+
47
+ commit: str = Field(description="The commit at which the new meaning starts.")
48
+ subject: str = Field(
49
+ description="What changed meaning: a field name, a metric, an arm label, a scorer.",
50
+ )
51
+ description: str = Field(description="What it meant before, and what it means now.")
52
+ component: str = Field(
53
+ default="",
54
+ description=(
55
+ "Which component changed. Empty means this application's own code, placed by its "
56
+ "revision. Named means a dependency, placed by the version the run recorded for it."
57
+ ),
58
+ )
59
+ min_version: str = Field(
60
+ default="",
61
+ description="First version of that component carrying the new meaning.",
62
+ )
63
+ effective_at: datetime = Field(
64
+ description=(
65
+ "When the commit landed. Used to place records whose own commit is not one we know "
66
+ "about, which is most of them."
67
+ )
68
+ )
69
+ declared_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
70
+ declared_by: str = Field(default="")
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class PoolVerdict:
75
+ """Whether a set of records may be compared as one population."""
76
+
77
+ poolable: bool
78
+ subject: str
79
+ counts: dict[Epoch, int]
80
+ reason: str = ""
81
+
82
+ @property
83
+ def records_examined(self) -> int:
84
+ return sum(self.counts.values())
85
+
86
+ def summary(self) -> str:
87
+ if self.records_examined == 0:
88
+ return f"inconclusive: no records examined for {self.subject!r}"
89
+ if self.poolable:
90
+ return f"poolable: {self.records_examined} records, all on one side of {self.subject!r}"
91
+ return f"not poolable: {self.reason}"
92
+
93
+
94
+ def _version_tuple(value: str) -> tuple[int, ...] | None:
95
+ """Numeric parts of a dotted version, or None when it cannot be read as one.
96
+
97
+ Unreadable is not the same as old. A version this cannot parse yields no placement rather than
98
+ a guess, because a guess here silently pools two populations.
99
+ """
100
+ parts: list[int] = []
101
+ for chunk in value.split("."):
102
+ digits = "".join(c for c in chunk if c.isdigit())
103
+ if not digits:
104
+ break
105
+ parts.append(int(digits))
106
+ return tuple(parts) or None
107
+
108
+
109
+ def classify(record, change: MeaningChange) -> Epoch:
110
+ """Place one record relative to one declared boundary.
111
+
112
+ A record whose own revision is the declaring commit is on the new side, because the commit is
113
+ the first to carry the new meaning.
114
+
115
+ A boundary declared on a component is placed by the version the run recorded for it. A run that
116
+ recorded no version for that component cannot be placed, which is the case that appears the
117
+ moment an application starts importing a library that moves underneath it.
118
+ """
119
+ if change.component:
120
+ versions = getattr(record, "component_versions", None) or {}
121
+ seen = versions.get(change.component)
122
+ if not seen or not change.min_version:
123
+ return Epoch.UNKNOWN
124
+ left, right = _version_tuple(seen), _version_tuple(change.min_version)
125
+ if left is None or right is None:
126
+ return Epoch.UNKNOWN
127
+ return Epoch.AFTER if left >= right else Epoch.BEFORE
128
+
129
+ revision = getattr(record, "code_revision", "") or ""
130
+ if revision and revision == change.commit:
131
+ return Epoch.AFTER
132
+
133
+ created = getattr(record, "created_at", None)
134
+ if created is None or change.effective_at is None:
135
+ return Epoch.UNKNOWN
136
+
137
+ # A record with no recorded revision can still be placed by time, but only if we accept that
138
+ # a rebuilt or backfilled record may carry a misleading timestamp. Say so at the call site.
139
+ if not revision:
140
+ return Epoch.UNKNOWN
141
+
142
+ record_at = created if created.tzinfo else created.replace(tzinfo=timezone.utc)
143
+ boundary_at = (
144
+ change.effective_at
145
+ if change.effective_at.tzinfo
146
+ else change.effective_at.replace(tzinfo=timezone.utc)
147
+ )
148
+ return Epoch.AFTER if record_at >= boundary_at else Epoch.BEFORE
149
+
150
+
151
+ def classify_all(records: Iterable, change: MeaningChange) -> dict[Epoch, int]:
152
+ """Count how a set of records falls either side of a boundary."""
153
+ counts = {Epoch.BEFORE: 0, Epoch.AFTER: 0, Epoch.UNKNOWN: 0}
154
+ for record in records:
155
+ counts[classify(record, change)] += 1
156
+ return counts
157
+
158
+
159
+ def check_poolable(records: Sequence, change: MeaningChange) -> PoolVerdict:
160
+ """Whether these records may be treated as one population across this boundary.
161
+
162
+ Refused when they straddle the boundary, and refused when any record cannot be placed. The
163
+ second refusal is the one that feels excessive and is the one that pays: an unplaceable record
164
+ is exactly the case where a guess is invisible afterwards.
165
+ """
166
+ counts = classify_all(records, change)
167
+ if counts[Epoch.UNKNOWN]:
168
+ return PoolVerdict(
169
+ poolable=False,
170
+ subject=change.subject,
171
+ counts=counts,
172
+ reason=(
173
+ f"{counts[Epoch.UNKNOWN]} record(s) carry no usable code revision, so they cannot "
174
+ f"be placed relative to {change.commit}"
175
+ ),
176
+ )
177
+ if counts[Epoch.BEFORE] and counts[Epoch.AFTER]:
178
+ return PoolVerdict(
179
+ poolable=False,
180
+ subject=change.subject,
181
+ counts=counts,
182
+ reason=(
183
+ f"records straddle {change.commit}: {counts[Epoch.BEFORE]} before and "
184
+ f"{counts[Epoch.AFTER]} after, where {change.subject!r} changed meaning"
185
+ ),
186
+ )
187
+ return PoolVerdict(poolable=True, subject=change.subject, counts=counts)
@@ -0,0 +1,135 @@
1
+ """Integrity of the run record chain.
2
+
3
+ Not tamper-evidence, which presupposes an adversary this platform does not have. This detects
4
+ accidental edits, partial writes and silent corruption in a corpus nobody can afford to re-run,
5
+ which is a smaller claim and the one the mechanism actually supports.
6
+
7
+ Both regimes the platform has to answer to ask for records that can be shown to be intact. The
8
+ AI Act asks providers of high-risk systems to keep automatically generated logs; the Digital
9
+ Omnibus of July 2026 moved the Annex III date to December 2027 and left the requirement as it
10
+ was. The Dutch Cybersecurity Act, in force since 15 August 2026 with no transition period, asks
11
+ in-scope entities for incident handling and logging that stands up afterwards. In both cases a record that could have been edited after the
12
+ fact is weaker evidence than one that could not.
13
+
14
+ The mechanism here is deliberately modest. Each record gets a content hash over its
15
+ audit-relevant fields, and each hash includes the previous one for its tenant, so removing or
16
+ altering a record breaks every hash after it. This is a hash chain, not a ledger and not a
17
+ signature. It detects tampering by anyone who does not rewrite the whole chain, which covers
18
+ accident and casual edits. It does not defend against an attacker with write access to the
19
+ database and the will to recompute, and it should never be described as if it does.
20
+
21
+ Verification is a separate function from creation so it can be run on demand and on a schedule,
22
+ which is what makes it evidence rather than decoration.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ from collections.abc import Iterable, Sequence
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ GENESIS = "0" * 64
34
+ """The previous hash of the first record in a tenant's chain."""
35
+
36
+ AUDIT_FIELDS = (
37
+ "run_id",
38
+ "created_at",
39
+ "tenant",
40
+ "item",
41
+ "arm",
42
+ "arm_fingerprint",
43
+ "model",
44
+ "endpoint",
45
+ "precision",
46
+ "code_revision",
47
+ "status",
48
+ "failure_kind",
49
+ "resolved",
50
+ "label_source",
51
+ "degraded",
52
+ "instrument",
53
+ )
54
+ """Fields covered by the hash.
55
+
56
+ Deliberately excludes the transcript. A transcript can be very large and is stored alongside
57
+ rather than inline, so hashing it here would make verification cost the whole corpus. What is
58
+ covered is the part an audit turns on: what ran, under what configuration, what was decided, and
59
+ who decided it.
60
+ """
61
+
62
+
63
+ def _canonical(values: dict[str, Any]) -> bytes:
64
+ """Stable bytes for a mapping, so the same record always hashes the same way."""
65
+ return json.dumps(
66
+ values, sort_keys=True, separators=(",", ":"), default=str
67
+ ).encode("utf-8")
68
+
69
+
70
+ def content_hash(record: Any, previous_hash: str = GENESIS) -> str:
71
+ """Hash of a record's audit fields, chained to the previous record for its tenant."""
72
+ values = {field: getattr(record, field, None) for field in AUDIT_FIELDS}
73
+ values["previous_hash"] = previous_hash
74
+ return hashlib.sha256(_canonical(values)).hexdigest()
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ChainVerdict:
79
+ """Whether a chain is intact, and enough context to read a clean result honestly."""
80
+
81
+ intact: bool
82
+ records_checked: int
83
+ first_broken_index: int | None = None
84
+ detail: str = ""
85
+
86
+ @property
87
+ def could_have_failed(self) -> bool:
88
+ """False when the input was too short to demonstrate anything.
89
+
90
+ An empty or single-record chain verifies trivially. Reporting that as intact without
91
+ saying so invites it to be read as evidence.
92
+ """
93
+ return self.records_checked >= 2
94
+
95
+ def summary(self) -> str:
96
+ if not self.could_have_failed:
97
+ return f"inconclusive: {self.records_checked} record(s), too few to verify a chain"
98
+ if self.intact:
99
+ return f"intact: {self.records_checked} records verified"
100
+ return f"broken at index {self.first_broken_index}: {self.detail}"
101
+
102
+
103
+ def build_chain(records: Sequence[Any]) -> list[str]:
104
+ """Hashes for a sequence of records in the order they were written."""
105
+ hashes: list[str] = []
106
+ previous = GENESIS
107
+ for record in records:
108
+ previous = content_hash(record, previous)
109
+ hashes.append(previous)
110
+ return hashes
111
+
112
+
113
+ def verify_chain(records: Sequence[Any], hashes: Iterable[str]) -> ChainVerdict:
114
+ """Recompute a chain and report the first place it diverges."""
115
+ stored = list(hashes)
116
+ if len(stored) != len(records):
117
+ return ChainVerdict(
118
+ intact=False,
119
+ records_checked=min(len(stored), len(records)),
120
+ first_broken_index=min(len(stored), len(records)),
121
+ detail=f"{len(records)} records against {len(stored)} hashes",
122
+ )
123
+
124
+ previous = GENESIS
125
+ for index, (record, expected) in enumerate(zip(records, stored, strict=True)):
126
+ actual = content_hash(record, previous)
127
+ if actual != expected:
128
+ return ChainVerdict(
129
+ intact=False,
130
+ records_checked=len(records),
131
+ first_broken_index=index,
132
+ detail="record content does not match its recorded hash",
133
+ )
134
+ previous = actual
135
+ return ChainVerdict(intact=True, records_checked=len(records))