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.
- agentic_base/__init__.py +0 -0
- agentic_base/client.py +172 -0
- agentic_base/code_policy/__init__.py +0 -0
- agentic_base/code_policy/policy.py +122 -0
- agentic_base/config.py +24 -0
- agentic_base/db.py +42 -0
- agentic_base/domain/__init__.py +1 -0
- agentic_base/domain/epochs.py +187 -0
- agentic_base/domain/integrity.py +135 -0
- agentic_base/domain/outcomes.py +260 -0
- agentic_base/domain/run_record.py +190 -0
- agentic_base/domain/validity.py +254 -0
- agentic_base/hpc/__init__.py +0 -0
- agentic_base/hpc/clusters.py +165 -0
- agentic_base/hpc/job_result.py +129 -0
- agentic_base/hpc/profiles/lumi.yaml +74 -0
- agentic_base/hpc/profiles/snellius.yaml +73 -0
- agentic_base/limits.py +56 -0
- agentic_base/llm/__init__.py +0 -0
- agentic_base/llm/health.py +136 -0
- agentic_base/llm/resilience.py +87 -0
- agentic_base/main.py +66 -0
- agentic_base/mcp/__init__.py +0 -0
- agentic_base/mcp/server.py +266 -0
- agentic_base/observability/__init__.py +0 -0
- agentic_base/observability/conventions.py +183 -0
- agentic_base/observability/tracing.py +109 -0
- agentic_base/provenance/__init__.py +25 -0
- agentic_base/provenance/emit.py +272 -0
- agentic_base/py.typed +0 -0
- agentic_base/recording.py +131 -0
- agentic_base/routers/__init__.py +0 -0
- agentic_base/routers/health.py +72 -0
- agentic_base/routers/runs.py +157 -0
- agentic_base/security/__init__.py +0 -0
- agentic_base/security/netsec.py +258 -0
- agentic_base/tools/__init__.py +0 -0
- agentic_base/tools/types.py +139 -0
- agentic_base/utils/__init__.py +1 -0
- agentic_base/utils/logging.py +203 -0
- surf_agentic_base-0.3.0.dist-info/METADATA +144 -0
- surf_agentic_base-0.3.0.dist-info/RECORD +45 -0
- surf_agentic_base-0.3.0.dist-info/WHEEL +5 -0
- surf_agentic_base-0.3.0.dist-info/licenses/LICENSE +190 -0
- surf_agentic_base-0.3.0.dist-info/top_level.txt +1 -0
agentic_base/__init__.py
ADDED
|
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))
|