allela 0.1.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.
- allela-0.1.0/PKG-INFO +69 -0
- allela-0.1.0/README.md +57 -0
- allela-0.1.0/allela/__init__.py +36 -0
- allela-0.1.0/allela/core.py +496 -0
- allela-0.1.0/allela.egg-info/PKG-INFO +69 -0
- allela-0.1.0/allela.egg-info/SOURCES.txt +9 -0
- allela-0.1.0/allela.egg-info/dependency_links.txt +1 -0
- allela-0.1.0/allela.egg-info/requires.txt +6 -0
- allela-0.1.0/allela.egg-info/top_level.txt +1 -0
- allela-0.1.0/pyproject.toml +21 -0
- allela-0.1.0/setup.cfg +4 -0
allela-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allela
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Operational Flight Recorder for AI — tamper-evident, DigiCert co-signed decision attestation
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: cryptography>=41.0
|
|
8
|
+
Requires-Dist: asn1crypto>=1.5
|
|
9
|
+
Requires-Dist: rfc8785>=0.1.2
|
|
10
|
+
Provides-Extra: api
|
|
11
|
+
Requires-Dist: fastapi[standard]>=0.110; extra == "api"
|
|
12
|
+
|
|
13
|
+
# Allela SDK
|
|
14
|
+
|
|
15
|
+
Tamper-evident, DigiCert co-signed attestation for AI decisions.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
From this folder:
|
|
20
|
+
|
|
21
|
+
pip install -e .
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import allela
|
|
27
|
+
|
|
28
|
+
allela.configure(
|
|
29
|
+
tenant_id="acme_corp",
|
|
30
|
+
model_provider="anthropic",
|
|
31
|
+
model_id="claude-sonnet-4-6",
|
|
32
|
+
model_version="20250929",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@allela.trace(system_prompt="You are a credit risk assistant.")
|
|
36
|
+
def make_credit_decision(applicant_profile: str) -> str:
|
|
37
|
+
return call_your_llm(applicant_profile) # your existing AI call, unchanged
|
|
38
|
+
|
|
39
|
+
result = make_credit_decision("Applicant: income $60,000, requesting $10,000 loan.")
|
|
40
|
+
# `result` is returned immediately — attestation happens on a background
|
|
41
|
+
# thread and does not add latency to your function call.
|
|
42
|
+
|
|
43
|
+
# Before your process exits, flush any in-flight attestations:
|
|
44
|
+
allela.shutdown()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Verifying a decision later
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
result = allela.reconcile(
|
|
51
|
+
event_id="evt_...",
|
|
52
|
+
raw_input="Applicant: income $60,000, requesting $10,000 loan.",
|
|
53
|
+
raw_output="APPROVED: Low risk profile.",
|
|
54
|
+
)
|
|
55
|
+
print(result["path"]) # FULL_INTEGRITY_VERIFICATION / DATA_UNAVAILABLE_AT_SOURCE / TAMPER_DETECTED
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What gets stored
|
|
59
|
+
|
|
60
|
+
Only cryptographic hashes and metadata (model ID/version, timestamps,
|
|
61
|
+
a hash of the system prompt) — never your raw input/output text. See
|
|
62
|
+
the Decision Event Schema (`v1/decision-event.json`) for the exact shape.
|
|
63
|
+
|
|
64
|
+
## Important: call `allela.shutdown()` before exiting
|
|
65
|
+
|
|
66
|
+
Because attestation runs on a background worker so it never blocks your
|
|
67
|
+
application, an attestation that's still in flight when your process
|
|
68
|
+
exits abruptly could be lost. Call `allela.shutdown()` (which flushes
|
|
69
|
+
the queue) during your application's graceful shutdown sequence.
|
allela-0.1.0/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Allela SDK
|
|
2
|
+
|
|
3
|
+
Tamper-evident, DigiCert co-signed attestation for AI decisions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
From this folder:
|
|
8
|
+
|
|
9
|
+
pip install -e .
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import allela
|
|
15
|
+
|
|
16
|
+
allela.configure(
|
|
17
|
+
tenant_id="acme_corp",
|
|
18
|
+
model_provider="anthropic",
|
|
19
|
+
model_id="claude-sonnet-4-6",
|
|
20
|
+
model_version="20250929",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@allela.trace(system_prompt="You are a credit risk assistant.")
|
|
24
|
+
def make_credit_decision(applicant_profile: str) -> str:
|
|
25
|
+
return call_your_llm(applicant_profile) # your existing AI call, unchanged
|
|
26
|
+
|
|
27
|
+
result = make_credit_decision("Applicant: income $60,000, requesting $10,000 loan.")
|
|
28
|
+
# `result` is returned immediately — attestation happens on a background
|
|
29
|
+
# thread and does not add latency to your function call.
|
|
30
|
+
|
|
31
|
+
# Before your process exits, flush any in-flight attestations:
|
|
32
|
+
allela.shutdown()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Verifying a decision later
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
result = allela.reconcile(
|
|
39
|
+
event_id="evt_...",
|
|
40
|
+
raw_input="Applicant: income $60,000, requesting $10,000 loan.",
|
|
41
|
+
raw_output="APPROVED: Low risk profile.",
|
|
42
|
+
)
|
|
43
|
+
print(result["path"]) # FULL_INTEGRITY_VERIFICATION / DATA_UNAVAILABLE_AT_SOURCE / TAMPER_DETECTED
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What gets stored
|
|
47
|
+
|
|
48
|
+
Only cryptographic hashes and metadata (model ID/version, timestamps,
|
|
49
|
+
a hash of the system prompt) — never your raw input/output text. See
|
|
50
|
+
the Decision Event Schema (`v1/decision-event.json`) for the exact shape.
|
|
51
|
+
|
|
52
|
+
## Important: call `allela.shutdown()` before exiting
|
|
53
|
+
|
|
54
|
+
Because attestation runs on a background worker so it never blocks your
|
|
55
|
+
application, an attestation that's still in flight when your process
|
|
56
|
+
exits abruptly could be lost. Call `allela.shutdown()` (which flushes
|
|
57
|
+
the queue) during your application's graceful shutdown sequence.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Allela SDK — Public API
|
|
3
|
+
=========================
|
|
4
|
+
import allela
|
|
5
|
+
|
|
6
|
+
allela.configure(
|
|
7
|
+
tenant_id="acme_corp",
|
|
8
|
+
model_provider="anthropic",
|
|
9
|
+
model_id="claude-sonnet-4-6",
|
|
10
|
+
model_version="20250929",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
@allela.trace
|
|
14
|
+
def make_credit_decision(applicant_profile: str) -> str:
|
|
15
|
+
return call_your_llm(applicant_profile)
|
|
16
|
+
|
|
17
|
+
result = make_credit_decision("Applicant: ...")
|
|
18
|
+
# The function returns immediately — attestation happens on a
|
|
19
|
+
# background worker thread and does not add latency to your call.
|
|
20
|
+
|
|
21
|
+
# Later, to verify:
|
|
22
|
+
result = allela.reconcile(event_id, raw_input="...", raw_output="...")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .core import (
|
|
26
|
+
configure,
|
|
27
|
+
trace,
|
|
28
|
+
attest,
|
|
29
|
+
reconcile,
|
|
30
|
+
get_event,
|
|
31
|
+
list_events,
|
|
32
|
+
shutdown,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = ["configure", "trace", "attest", "reconcile", "get_event", "list_events", "shutdown"]
|
|
36
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Allela SDK Core
|
|
3
|
+
=================
|
|
4
|
+
Self-contained implementation (no dependency on loose script files) of:
|
|
5
|
+
- Ed25519 signing / SHA-256 hashing
|
|
6
|
+
- DigiCert RFC 3161 co-signing
|
|
7
|
+
- Decision Event Schema (v1) with JCS canonicalization
|
|
8
|
+
- Append-only SQLite ledger
|
|
9
|
+
- Reconciliation Protocol (3 paths)
|
|
10
|
+
- The @allela.trace decorator, backed by a background async worker so
|
|
11
|
+
attestation NEVER blocks the decorated function's return.
|
|
12
|
+
|
|
13
|
+
This consolidates the logic already tested in allela.py and
|
|
14
|
+
decision_event.py into a single, pip-installable package.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import functools
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import queue
|
|
24
|
+
import secrets
|
|
25
|
+
import sqlite3
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
import urllib.request
|
|
29
|
+
import uuid
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
|
|
32
|
+
from cryptography.exceptions import InvalidSignature
|
|
33
|
+
from cryptography.hazmat.primitives import serialization
|
|
34
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
35
|
+
Ed25519PrivateKey,
|
|
36
|
+
Ed25519PublicKey,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from asn1crypto import algos, tsp
|
|
41
|
+
except ImportError:
|
|
42
|
+
algos = None
|
|
43
|
+
tsp = None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import rfc8785
|
|
47
|
+
_HAVE_RFC8785 = True
|
|
48
|
+
except ImportError:
|
|
49
|
+
_HAVE_RFC8785 = False
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("allela")
|
|
52
|
+
|
|
53
|
+
# --------------------------------------------------------------------------
|
|
54
|
+
# Configuration (set once via allela.configure(...))
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
_CONFIG = {
|
|
57
|
+
"tenant_id": None,
|
|
58
|
+
"model_provider": None,
|
|
59
|
+
"model_id": None,
|
|
60
|
+
"model_version": None,
|
|
61
|
+
"environment": "production",
|
|
62
|
+
"db_path": "allela_ledger.db",
|
|
63
|
+
"private_key_path": "allela_private.key",
|
|
64
|
+
"public_key_path": "allela_public.key",
|
|
65
|
+
"tsa_url": "http://timestamp.digicert.com",
|
|
66
|
+
"intent_timeout_seconds": 30,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def configure(
|
|
71
|
+
tenant_id: str,
|
|
72
|
+
model_provider: str,
|
|
73
|
+
model_id: str,
|
|
74
|
+
model_version: str,
|
|
75
|
+
environment: str = "production",
|
|
76
|
+
db_path: str = "allela_ledger.db",
|
|
77
|
+
private_key_path: str = "allela_private.key",
|
|
78
|
+
public_key_path: str = "allela_public.key",
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Must be called once before using @allela.trace or allela.attest()."""
|
|
81
|
+
_CONFIG.update({
|
|
82
|
+
"tenant_id": tenant_id,
|
|
83
|
+
"model_provider": model_provider,
|
|
84
|
+
"model_id": model_id,
|
|
85
|
+
"model_version": model_version,
|
|
86
|
+
"environment": environment,
|
|
87
|
+
"db_path": db_path,
|
|
88
|
+
"private_key_path": private_key_path,
|
|
89
|
+
"public_key_path": public_key_path,
|
|
90
|
+
})
|
|
91
|
+
_init_db()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _require_configured():
|
|
95
|
+
if _CONFIG["tenant_id"] is None:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"allela.configure(...) must be called before use. "
|
|
98
|
+
"See the module docstring for an example."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
# Crypto primitives
|
|
104
|
+
# --------------------------------------------------------------------------
|
|
105
|
+
def sha256_hex(text: str) -> str:
|
|
106
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _load_or_generate_keypair():
|
|
110
|
+
priv_path = _CONFIG["private_key_path"]
|
|
111
|
+
pub_path = _CONFIG["public_key_path"]
|
|
112
|
+
if os.path.exists(priv_path):
|
|
113
|
+
with open(priv_path, "rb") as f:
|
|
114
|
+
private_key = serialization.load_pem_private_key(f.read(), password=None)
|
|
115
|
+
else:
|
|
116
|
+
private_key = Ed25519PrivateKey.generate()
|
|
117
|
+
pem = private_key.private_bytes(
|
|
118
|
+
encoding=serialization.Encoding.PEM,
|
|
119
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
120
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
121
|
+
)
|
|
122
|
+
with open(priv_path, "wb") as f:
|
|
123
|
+
f.write(pem)
|
|
124
|
+
try:
|
|
125
|
+
os.chmod(priv_path, 0o600)
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
public_key = private_key.public_key()
|
|
129
|
+
with open(pub_path, "wb") as f:
|
|
130
|
+
f.write(public_key.public_bytes(
|
|
131
|
+
encoding=serialization.Encoding.PEM,
|
|
132
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
133
|
+
))
|
|
134
|
+
return private_key, public_key
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _load_public_key():
|
|
138
|
+
with open(_CONFIG["public_key_path"], "rb") as f:
|
|
139
|
+
return serialization.load_pem_public_key(f.read())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def canonicalize(obj: dict) -> bytes:
|
|
143
|
+
if _HAVE_RFC8785:
|
|
144
|
+
return rfc8785.dumps(obj)
|
|
145
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_rfc3161_timestamp(digest_hex: str) -> str:
|
|
149
|
+
if tsp is None or algos is None:
|
|
150
|
+
raise RuntimeError("asn1crypto is not installed. Run: pip install asn1crypto")
|
|
151
|
+
digest = bytes.fromhex(digest_hex)
|
|
152
|
+
nonce = int.from_bytes(secrets.token_bytes(16), "big")
|
|
153
|
+
req = tsp.TimeStampReq({
|
|
154
|
+
"version": 1,
|
|
155
|
+
"message_imprint": tsp.MessageImprint({
|
|
156
|
+
"hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}),
|
|
157
|
+
"hashed_message": digest,
|
|
158
|
+
}),
|
|
159
|
+
"nonce": nonce,
|
|
160
|
+
"cert_req": True,
|
|
161
|
+
})
|
|
162
|
+
req_der = req.dump()
|
|
163
|
+
http_req = urllib.request.Request(
|
|
164
|
+
_CONFIG["tsa_url"], data=req_der,
|
|
165
|
+
headers={"Content-Type": "application/timestamp-query", "Content-Length": str(len(req_der))},
|
|
166
|
+
method="POST",
|
|
167
|
+
)
|
|
168
|
+
with urllib.request.urlopen(http_req, timeout=20) as resp:
|
|
169
|
+
resp_der = resp.read()
|
|
170
|
+
tsr = tsp.TimeStampResp.load(resp_der)
|
|
171
|
+
status_str = tsr["status"]["status"].native
|
|
172
|
+
if status_str not in ("granted", "grantedWithMods", "granted_with_mods"):
|
|
173
|
+
raise RuntimeError(f"TSA refused the request: {status_str}")
|
|
174
|
+
return base64.b64encode(resp_der).decode("ascii")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _verify_rfc3161_token(token_b64: str, expected_digest_hex: str) -> bool:
|
|
178
|
+
if tsp is None:
|
|
179
|
+
return False
|
|
180
|
+
resp_der = base64.b64decode(token_b64)
|
|
181
|
+
tsr = tsp.TimeStampResp.load(resp_der)
|
|
182
|
+
if tsr["status"]["status"].native not in ("granted", "grantedWithMods", "granted_with_mods"):
|
|
183
|
+
return False
|
|
184
|
+
content_info = tsr["time_stamp_token"]
|
|
185
|
+
if content_info is None:
|
|
186
|
+
return False
|
|
187
|
+
signed_data = content_info["content"]
|
|
188
|
+
econtent = signed_data["encap_content_info"]["content"]
|
|
189
|
+
tst_info = econtent if isinstance(econtent, tsp.TSTInfo) else tsp.TSTInfo.load(bytes(econtent))
|
|
190
|
+
imprint = tst_info["message_imprint"]["hashed_message"].native
|
|
191
|
+
return imprint == bytes.fromhex(expected_digest_hex)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------
|
|
195
|
+
# Storage
|
|
196
|
+
# --------------------------------------------------------------------------
|
|
197
|
+
def _get_conn() -> sqlite3.Connection:
|
|
198
|
+
conn = sqlite3.connect(_CONFIG["db_path"])
|
|
199
|
+
conn.row_factory = sqlite3.Row
|
|
200
|
+
return conn
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _init_db() -> None:
|
|
204
|
+
conn = _get_conn()
|
|
205
|
+
conn.executescript(
|
|
206
|
+
"""
|
|
207
|
+
CREATE TABLE IF NOT EXISTS decision_events (
|
|
208
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
209
|
+
event_id TEXT UNIQUE NOT NULL,
|
|
210
|
+
tenant_id TEXT NOT NULL,
|
|
211
|
+
timestamp_utc TEXT NOT NULL,
|
|
212
|
+
canonical_json TEXT NOT NULL,
|
|
213
|
+
root_hash TEXT NOT NULL,
|
|
214
|
+
signature_hex TEXT NOT NULL,
|
|
215
|
+
digicert_token_b64 TEXT,
|
|
216
|
+
status TEXT NOT NULL,
|
|
217
|
+
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
218
|
+
);
|
|
219
|
+
CREATE TRIGGER IF NOT EXISTS decision_events_no_update
|
|
220
|
+
BEFORE UPDATE ON decision_events
|
|
221
|
+
BEGIN SELECT RAISE(ABORT, 'append-only: UPDATE blocked'); END;
|
|
222
|
+
CREATE TRIGGER IF NOT EXISTS decision_events_no_delete
|
|
223
|
+
BEFORE DELETE ON decision_events
|
|
224
|
+
BEGIN SELECT RAISE(ABORT, 'append-only: DELETE blocked'); END;
|
|
225
|
+
|
|
226
|
+
CREATE TABLE IF NOT EXISTS intent_gaps (
|
|
227
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
228
|
+
intent_id TEXT NOT NULL,
|
|
229
|
+
flagged_at TEXT NOT NULL,
|
|
230
|
+
reason TEXT
|
|
231
|
+
);
|
|
232
|
+
"""
|
|
233
|
+
)
|
|
234
|
+
conn.commit()
|
|
235
|
+
conn.close()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --------------------------------------------------------------------------
|
|
239
|
+
# Decision Event Schema + attestation
|
|
240
|
+
# --------------------------------------------------------------------------
|
|
241
|
+
def _build_event(input_text: str, output_text: str, system_prompt: str = "",
|
|
242
|
+
execution_params: dict = None) -> dict:
|
|
243
|
+
return {
|
|
244
|
+
"$schema": "https://allela.ai/schemas/v1/decision-event.json",
|
|
245
|
+
"event_id": f"evt_{uuid.uuid4().hex}",
|
|
246
|
+
"tenant_id": _CONFIG["tenant_id"],
|
|
247
|
+
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
248
|
+
"system_metadata": {
|
|
249
|
+
"environment": _CONFIG["environment"],
|
|
250
|
+
"model_provider": _CONFIG["model_provider"],
|
|
251
|
+
"model_id": _CONFIG["model_id"],
|
|
252
|
+
"model_version": _CONFIG["model_version"],
|
|
253
|
+
"system_prompt_hash": sha256_hex(system_prompt) if system_prompt else "",
|
|
254
|
+
"execution_params": execution_params or {},
|
|
255
|
+
},
|
|
256
|
+
"payload_hashes": {
|
|
257
|
+
"input_hash": sha256_hex(input_text),
|
|
258
|
+
"output_hash": sha256_hex(output_text),
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _attest_event(event: dict) -> str:
|
|
264
|
+
canonical_bytes = canonicalize(event)
|
|
265
|
+
root_hash = hashlib.sha256(canonical_bytes).hexdigest()
|
|
266
|
+
private_key, _ = _load_or_generate_keypair()
|
|
267
|
+
signature_hex = private_key.sign(canonical_bytes).hex()
|
|
268
|
+
|
|
269
|
+
status = "success"
|
|
270
|
+
try:
|
|
271
|
+
digicert_token_b64 = _get_rfc3161_timestamp(root_hash)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.warning("DigiCert timestamping failed (%s); recording as signed_no_tsa", e)
|
|
274
|
+
digicert_token_b64 = None
|
|
275
|
+
status = "signed_no_tsa"
|
|
276
|
+
|
|
277
|
+
conn = _get_conn()
|
|
278
|
+
conn.execute(
|
|
279
|
+
"""INSERT INTO decision_events
|
|
280
|
+
(event_id, tenant_id, timestamp_utc, canonical_json, root_hash,
|
|
281
|
+
signature_hex, digicert_token_b64, status)
|
|
282
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
283
|
+
(event["event_id"], event["tenant_id"], event["timestamp_utc"],
|
|
284
|
+
canonical_bytes.decode("utf-8"), root_hash, signature_hex,
|
|
285
|
+
digicert_token_b64, status),
|
|
286
|
+
)
|
|
287
|
+
conn.commit()
|
|
288
|
+
conn.close()
|
|
289
|
+
logger.info("Attested %s (status=%s)", event["event_id"], status)
|
|
290
|
+
return event["event_id"]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def attest(input_text: str, output_text: str, system_prompt: str = "",
|
|
294
|
+
execution_params: dict = None, blocking: bool = True) -> str:
|
|
295
|
+
"""Directly attest an input/output pair. If blocking=False, this is
|
|
296
|
+
queued to the background worker and returns immediately (event_id is
|
|
297
|
+
still generated and returned synchronously; the DigiCert timestamp
|
|
298
|
+
and DB write happen asynchronously)."""
|
|
299
|
+
_require_configured()
|
|
300
|
+
event = _build_event(input_text, output_text, system_prompt, execution_params)
|
|
301
|
+
if blocking:
|
|
302
|
+
return _attest_event(event)
|
|
303
|
+
_WORKER.submit(event)
|
|
304
|
+
return event["event_id"]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --------------------------------------------------------------------------
|
|
308
|
+
# Background async worker (so @trace never blocks the caller)
|
|
309
|
+
# --------------------------------------------------------------------------
|
|
310
|
+
class _AsyncWorker:
|
|
311
|
+
"""Runs attestation (hashing already done, but signing + DigiCert +
|
|
312
|
+
DB write) on a background daemon thread, off the critical path of
|
|
313
|
+
whatever function is being traced."""
|
|
314
|
+
|
|
315
|
+
def __init__(self):
|
|
316
|
+
self._queue: "queue.Queue[dict]" = queue.Queue()
|
|
317
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
318
|
+
self._started = False
|
|
319
|
+
|
|
320
|
+
def _ensure_started(self):
|
|
321
|
+
if not self._started:
|
|
322
|
+
self._thread.start()
|
|
323
|
+
self._started = True
|
|
324
|
+
|
|
325
|
+
def submit(self, event: dict) -> None:
|
|
326
|
+
self._ensure_started()
|
|
327
|
+
self._queue.put(event)
|
|
328
|
+
|
|
329
|
+
def _run(self):
|
|
330
|
+
while True:
|
|
331
|
+
event = self._queue.get()
|
|
332
|
+
if event is None: # shutdown sentinel
|
|
333
|
+
break
|
|
334
|
+
try:
|
|
335
|
+
_attest_event(event)
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.exception("Background attestation failed for %s", event.get("event_id"))
|
|
338
|
+
finally:
|
|
339
|
+
self._queue.task_done()
|
|
340
|
+
|
|
341
|
+
def flush(self, timeout: float = 30.0):
|
|
342
|
+
"""Blocks until all queued attestations have been processed.
|
|
343
|
+
Useful in tests/demos and before process exit."""
|
|
344
|
+
start = time.monotonic()
|
|
345
|
+
self._queue.join()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
_WORKER = _AsyncWorker()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def shutdown(flush: bool = True, timeout: float = 30.0) -> None:
|
|
352
|
+
"""Call before your process exits if you want to guarantee every
|
|
353
|
+
background-queued attestation has actually been written and
|
|
354
|
+
timestamped. Not calling this risks losing in-flight attestations
|
|
355
|
+
if the process exits abruptly — this is the SDK's own version of
|
|
356
|
+
the Intent Gap Detector's concern, applied to itself."""
|
|
357
|
+
if flush:
|
|
358
|
+
_WORKER.flush(timeout=timeout)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# --------------------------------------------------------------------------
|
|
362
|
+
# The @allela.trace decorator
|
|
363
|
+
# --------------------------------------------------------------------------
|
|
364
|
+
def trace(system_prompt: str = "", execution_params: dict = None):
|
|
365
|
+
"""Decorator that attests a function's (input, output) pair on every
|
|
366
|
+
call, asynchronously, without adding latency to the call itself.
|
|
367
|
+
|
|
368
|
+
Usage:
|
|
369
|
+
@allela.trace()
|
|
370
|
+
def make_decision(prompt: str) -> str:
|
|
371
|
+
return call_llm(prompt)
|
|
372
|
+
|
|
373
|
+
# or with a fixed system prompt recorded on every call:
|
|
374
|
+
@allela.trace(system_prompt="You are a credit risk assistant.")
|
|
375
|
+
def make_decision(prompt: str) -> str:
|
|
376
|
+
return call_llm(prompt)
|
|
377
|
+
|
|
378
|
+
The function's positional/keyword arguments are captured (as a JSON
|
|
379
|
+
string) as the input; its return value (str) is captured as the
|
|
380
|
+
output. If your function's inputs/outputs aren't naturally strings,
|
|
381
|
+
wrap them with str()/json.dumps() before this decorator, or use
|
|
382
|
+
allela.attest() directly for full control."""
|
|
383
|
+
|
|
384
|
+
def decorator(func):
|
|
385
|
+
@functools.wraps(func)
|
|
386
|
+
def wrapper(*args, **kwargs):
|
|
387
|
+
_require_configured()
|
|
388
|
+
result = func(*args, **kwargs)
|
|
389
|
+
try:
|
|
390
|
+
input_repr = json.dumps({"args": [str(a) for a in args], "kwargs": kwargs}, sort_keys=True)
|
|
391
|
+
except TypeError:
|
|
392
|
+
input_repr = str(args) + str(kwargs)
|
|
393
|
+
output_repr = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
394
|
+
|
|
395
|
+
event = _build_event(input_repr, output_repr, system_prompt, execution_params)
|
|
396
|
+
_WORKER.submit(event) # never blocks the caller
|
|
397
|
+
return result
|
|
398
|
+
return wrapper
|
|
399
|
+
|
|
400
|
+
# allow @allela.trace (no parens) as well as @allela.trace(...)
|
|
401
|
+
if callable(system_prompt):
|
|
402
|
+
func = system_prompt
|
|
403
|
+
return trace()(func)
|
|
404
|
+
return decorator
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --------------------------------------------------------------------------
|
|
408
|
+
# Reconciliation Protocol
|
|
409
|
+
# --------------------------------------------------------------------------
|
|
410
|
+
def reconcile(event_id: str, raw_input: str = None, raw_output: str = None) -> dict:
|
|
411
|
+
conn = _get_conn()
|
|
412
|
+
cur = conn.execute("SELECT * FROM decision_events WHERE event_id = ?", (event_id,))
|
|
413
|
+
row = cur.fetchone()
|
|
414
|
+
if row is None:
|
|
415
|
+
conn.close()
|
|
416
|
+
return {"path": "NOT_FOUND", "event_id": event_id}
|
|
417
|
+
record = dict(row)
|
|
418
|
+
stored_event = json.loads(record["canonical_json"])
|
|
419
|
+
|
|
420
|
+
public_key = _load_public_key()
|
|
421
|
+
canonical_bytes = canonicalize(stored_event)
|
|
422
|
+
recomputed_root = hashlib.sha256(canonical_bytes).hexdigest()
|
|
423
|
+
try:
|
|
424
|
+
public_key.verify(bytes.fromhex(record["signature_hex"]), canonical_bytes)
|
|
425
|
+
sig_valid = True
|
|
426
|
+
except (InvalidSignature, ValueError):
|
|
427
|
+
sig_valid = False
|
|
428
|
+
root_matches = recomputed_root == record["root_hash"]
|
|
429
|
+
|
|
430
|
+
tsa_present = record["digicert_token_b64"] is not None
|
|
431
|
+
tsa_verified = tsa_present and _verify_rfc3161_token(record["digicert_token_b64"], record["root_hash"])
|
|
432
|
+
|
|
433
|
+
base = {
|
|
434
|
+
"event_id": event_id,
|
|
435
|
+
"tenant_id": record["tenant_id"],
|
|
436
|
+
"timestamp_utc": record["timestamp_utc"],
|
|
437
|
+
"model_id": stored_event["system_metadata"]["model_id"],
|
|
438
|
+
"model_version": stored_event["system_metadata"]["model_version"],
|
|
439
|
+
"ledger_signature_valid": sig_valid,
|
|
440
|
+
"ledger_root_hash_valid": root_matches,
|
|
441
|
+
"digicert_timestamp_verified": tsa_verified,
|
|
442
|
+
}
|
|
443
|
+
conn.close()
|
|
444
|
+
|
|
445
|
+
if raw_input is None and raw_output is None:
|
|
446
|
+
base.update({
|
|
447
|
+
"path": "DATA_UNAVAILABLE_AT_SOURCE",
|
|
448
|
+
"explanation": (
|
|
449
|
+
"No raw input/output was supplied. Allela confirms a decision "
|
|
450
|
+
"event existed at this timestamp, under this model and version — "
|
|
451
|
+
"but cannot confirm payload content without the caller's own data."
|
|
452
|
+
),
|
|
453
|
+
})
|
|
454
|
+
return base
|
|
455
|
+
|
|
456
|
+
input_matches = sha256_hex(raw_input or "") == stored_event["payload_hashes"]["input_hash"]
|
|
457
|
+
output_matches = sha256_hex(raw_output or "") == stored_event["payload_hashes"]["output_hash"]
|
|
458
|
+
|
|
459
|
+
if input_matches and output_matches:
|
|
460
|
+
base.update({"path": "FULL_INTEGRITY_VERIFICATION",
|
|
461
|
+
"explanation": "Supplied data matches the stored record exactly. Unaltered."})
|
|
462
|
+
else:
|
|
463
|
+
mismatched = [f for f, ok in (("input", input_matches), ("output", output_matches)) if not ok]
|
|
464
|
+
base.update({"path": "TAMPER_DETECTED", "mismatched_fields": mismatched,
|
|
465
|
+
"explanation": f"Mismatch detected in: {', '.join(mismatched)}. Record was modified after timestamping."})
|
|
466
|
+
return base
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def get_event(event_id: str) -> dict:
|
|
470
|
+
conn = _get_conn()
|
|
471
|
+
cur = conn.execute("SELECT * FROM decision_events WHERE event_id = ?", (event_id,))
|
|
472
|
+
row = cur.fetchone()
|
|
473
|
+
conn.close()
|
|
474
|
+
if row is None:
|
|
475
|
+
return None
|
|
476
|
+
record = dict(row)
|
|
477
|
+
return {
|
|
478
|
+
"event_id": record["event_id"],
|
|
479
|
+
"tenant_id": record["tenant_id"],
|
|
480
|
+
"timestamp_utc": record["timestamp_utc"],
|
|
481
|
+
"root_hash": record["root_hash"],
|
|
482
|
+
"status": record["status"],
|
|
483
|
+
"digicert_timestamp_present": record["digicert_token_b64"] is not None,
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def list_events(tenant_id: str, limit: int = 20, offset: int = 0) -> list:
|
|
488
|
+
conn = _get_conn()
|
|
489
|
+
cur = conn.execute(
|
|
490
|
+
"SELECT event_id, timestamp_utc, status FROM decision_events "
|
|
491
|
+
"WHERE tenant_id = ? ORDER BY id DESC LIMIT ? OFFSET ?",
|
|
492
|
+
(tenant_id, limit, offset),
|
|
493
|
+
)
|
|
494
|
+
rows = [dict(r) for r in cur.fetchall()]
|
|
495
|
+
conn.close()
|
|
496
|
+
return rows
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: allela
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Operational Flight Recorder for AI — tamper-evident, DigiCert co-signed decision attestation
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: cryptography>=41.0
|
|
8
|
+
Requires-Dist: asn1crypto>=1.5
|
|
9
|
+
Requires-Dist: rfc8785>=0.1.2
|
|
10
|
+
Provides-Extra: api
|
|
11
|
+
Requires-Dist: fastapi[standard]>=0.110; extra == "api"
|
|
12
|
+
|
|
13
|
+
# Allela SDK
|
|
14
|
+
|
|
15
|
+
Tamper-evident, DigiCert co-signed attestation for AI decisions.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
From this folder:
|
|
20
|
+
|
|
21
|
+
pip install -e .
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import allela
|
|
27
|
+
|
|
28
|
+
allela.configure(
|
|
29
|
+
tenant_id="acme_corp",
|
|
30
|
+
model_provider="anthropic",
|
|
31
|
+
model_id="claude-sonnet-4-6",
|
|
32
|
+
model_version="20250929",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@allela.trace(system_prompt="You are a credit risk assistant.")
|
|
36
|
+
def make_credit_decision(applicant_profile: str) -> str:
|
|
37
|
+
return call_your_llm(applicant_profile) # your existing AI call, unchanged
|
|
38
|
+
|
|
39
|
+
result = make_credit_decision("Applicant: income $60,000, requesting $10,000 loan.")
|
|
40
|
+
# `result` is returned immediately — attestation happens on a background
|
|
41
|
+
# thread and does not add latency to your function call.
|
|
42
|
+
|
|
43
|
+
# Before your process exits, flush any in-flight attestations:
|
|
44
|
+
allela.shutdown()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Verifying a decision later
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
result = allela.reconcile(
|
|
51
|
+
event_id="evt_...",
|
|
52
|
+
raw_input="Applicant: income $60,000, requesting $10,000 loan.",
|
|
53
|
+
raw_output="APPROVED: Low risk profile.",
|
|
54
|
+
)
|
|
55
|
+
print(result["path"]) # FULL_INTEGRITY_VERIFICATION / DATA_UNAVAILABLE_AT_SOURCE / TAMPER_DETECTED
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What gets stored
|
|
59
|
+
|
|
60
|
+
Only cryptographic hashes and metadata (model ID/version, timestamps,
|
|
61
|
+
a hash of the system prompt) — never your raw input/output text. See
|
|
62
|
+
the Decision Event Schema (`v1/decision-event.json`) for the exact shape.
|
|
63
|
+
|
|
64
|
+
## Important: call `allela.shutdown()` before exiting
|
|
65
|
+
|
|
66
|
+
Because attestation runs on a background worker so it never blocks your
|
|
67
|
+
application, an attestation that's still in flight when your process
|
|
68
|
+
exits abruptly could be lost. Call `allela.shutdown()` (which flushes
|
|
69
|
+
the queue) during your application's graceful shutdown sequence.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
allela
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "allela"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Operational Flight Recorder for AI — tamper-evident, DigiCert co-signed decision attestation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"cryptography>=41.0",
|
|
13
|
+
"asn1crypto>=1.5",
|
|
14
|
+
"rfc8785>=0.1.2",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
api = ["fastapi[standard]>=0.110"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["allela*"]
|
allela-0.1.0/setup.cfg
ADDED