jang0294 commited on
Commit
f3b126c
Β·
verified Β·
1 Parent(s): e280d04

Upload folder using huggingface_hub

Browse files
atp/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ATP β€” the Agentic Training Platform data + store layer.
3
+
4
+ `atp.get_data()` returns the full `window.ATP_DATA` payload (seed, cached).
5
+ The store also exposes append-only HITL reward logging and the compose /
6
+ train-to-order request log.
7
+ """
8
+
9
+ from .store import (
10
+ get_data,
11
+ append_hitl,
12
+ read_hitl,
13
+ append_request,
14
+ read_requests,
15
+ )
16
+
17
+ __all__ = [
18
+ "get_data",
19
+ "append_hitl",
20
+ "read_hitl",
21
+ "append_request",
22
+ "read_requests",
23
+ ]
atp/crypto.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Envelope encryption for T2 ("crown jewels") columns β€” docs/TENANCY.md.
3
+
4
+ Key model (leak surface #10 β€” one key must not unlock all orgs):
5
+
6
+ * Master key: `DATA_ENCRYPTION_KEY` env var, read at CALL time (never at
7
+ import). Any high-entropy string; its utf-8 bytes are the HKDF input key
8
+ material. Only ever supplied via env β€” never stored in the repo or DB.
9
+ * Per-org subkey: HKDF-SHA256(master, info=org_id) β†’ 32 bytes β†’ urlsafe-b64
10
+ β†’ Fernet key. Deterministic, so no key table is needed; compromise of one
11
+ derived key does not reveal the master or any sibling org's key.
12
+ * Rotation (re-wrap): stand up the new master alongside the old
13
+ (`DATA_ENCRYPTION_KEY_OLD` by operator convention), then for each T2 row:
14
+ decrypt with the old master's per-org subkey, encrypt with the new one,
15
+ write back. `enc1:` is the version tag β€” a future algorithm/rotation
16
+ scheme bumps to `enc2:` so mixed-version tables stay readable.
17
+
18
+ Wire format: `'enc1:' + Fernet token` stored in TEXT columns. Values WITHOUT
19
+ the prefix are legacy plaintext and read through unchanged β€” decryption of
20
+ legacy rows never requires a key, so pre-encryption databases keep working.
21
+
22
+ Fail-loud: encrypting anything, or decrypting an `enc1:` token, without
23
+ `DATA_ENCRYPTION_KEY` raises RuntimeError immediately (no silent plaintext
24
+ writes, no silent empty reads). A token that fails authentication (tampered,
25
+ or decrypted under the wrong org's subkey) raises ValueError.
26
+
27
+ Public:
28
+ encrypt_for_org(org_id, data) -> 'enc1:…' str (data: bytes or str)
29
+ decrypt_for_org(org_id, value) -> bytes | None (plaintext passthrough)
30
+ decrypt_text_for_org(org_id, value) -> str | None (utf-8 convenience)
31
+ is_encrypted(value) -> bool
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import base64
37
+ import os
38
+ import threading
39
+
40
+ from cryptography.fernet import Fernet, InvalidToken
41
+ from cryptography.hazmat.primitives import hashes
42
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
43
+
44
+ ENC_PREFIX = "enc1:"
45
+
46
+ _LOCK = threading.Lock()
47
+ # (master_bytes, org_id) -> Fernet. Keyed by master so tests / rotation that
48
+ # change the env var never serve a stale subkey.
49
+ _FERNET_CACHE: dict[tuple[bytes, str], Fernet] = {}
50
+
51
+
52
+ def _master() -> bytes:
53
+ """Return the master key bytes, failing loudly when encryption was
54
+ requested but no key is configured."""
55
+ key = os.environ.get("DATA_ENCRYPTION_KEY", "")
56
+ if not key.strip():
57
+ raise RuntimeError(
58
+ "DATA_ENCRYPTION_KEY is not set but an encrypt/decrypt of T2 "
59
+ "data was requested. Refusing to continue (docs/TENANCY.md data "
60
+ "class T2). Set DATA_ENCRYPTION_KEY in the deployment "
61
+ "environment (see .env.example); legacy plaintext values still "
62
+ "read through without it."
63
+ )
64
+ return key.strip().encode("utf-8")
65
+
66
+
67
+ def _fernet_for_org(org_id: str) -> Fernet:
68
+ """Per-org Fernet from HKDF-SHA256(master, info=org_id)."""
69
+ if not org_id or not isinstance(org_id, str):
70
+ raise ValueError("org_id must be a non-empty string")
71
+ master = _master()
72
+ cache_key = (master, org_id)
73
+ f = _FERNET_CACHE.get(cache_key)
74
+ if f is None:
75
+ with _LOCK:
76
+ f = _FERNET_CACHE.get(cache_key)
77
+ if f is None:
78
+ derived = HKDF(
79
+ algorithm=hashes.SHA256(),
80
+ length=32,
81
+ salt=None, # HKDF treats absent salt as zeros β€” fine:
82
+ # uniqueness comes from info=org_id
83
+ info=org_id.encode("utf-8"),
84
+ ).derive(master)
85
+ f = Fernet(base64.urlsafe_b64encode(derived))
86
+ _FERNET_CACHE[cache_key] = f
87
+ return f
88
+
89
+
90
+ def is_encrypted(value: str | bytes | None) -> bool:
91
+ """True when `value` carries the enc1 envelope prefix."""
92
+ if isinstance(value, bytes):
93
+ return value.startswith(ENC_PREFIX.encode("ascii"))
94
+ if isinstance(value, str):
95
+ return value.startswith(ENC_PREFIX)
96
+ return False
97
+
98
+
99
+ def encrypt_for_org(org_id: str, data: bytes | str) -> str:
100
+ """Encrypt `data` under `org_id`'s subkey β†’ 'enc1:<fernet token>'.
101
+
102
+ Fails loudly (RuntimeError) when DATA_ENCRYPTION_KEY is unset β€” callers
103
+ must never fall back to writing plaintext for T2 fields.
104
+ """
105
+ if isinstance(data, str):
106
+ data = data.encode("utf-8")
107
+ if not isinstance(data, bytes):
108
+ raise TypeError(f"encrypt_for_org expects bytes or str, got {type(data).__name__}")
109
+ token = _fernet_for_org(org_id).encrypt(data)
110
+ return ENC_PREFIX + token.decode("ascii")
111
+
112
+
113
+ def decrypt_for_org(org_id: str, value: str | bytes | None) -> bytes | None:
114
+ """Decrypt an 'enc1:' envelope; pass legacy plaintext through as bytes.
115
+
116
+ * None β†’ None.
117
+ * 'enc1:…' β†’ decrypted bytes (RuntimeError without a key; ValueError if
118
+ the token is tampered with or belongs to a different org's subkey).
119
+ * anything else β†’ returned unchanged as bytes (str is utf-8 encoded):
120
+ pre-encryption rows keep reading without DATA_ENCRYPTION_KEY.
121
+ """
122
+ if value is None:
123
+ return None
124
+ if not is_encrypted(value):
125
+ return value.encode("utf-8") if isinstance(value, str) else bytes(value)
126
+ token = value[len(ENC_PREFIX):] if isinstance(value, str) else value[len(ENC_PREFIX):].decode("ascii")
127
+ try:
128
+ return _fernet_for_org(org_id).decrypt(token.encode("ascii") if isinstance(token, str) else token)
129
+ except InvalidToken:
130
+ raise ValueError(
131
+ f"enc1 token failed to decrypt under org {org_id!r}: wrong org "
132
+ "subkey, wrong master key, or tampered ciphertext"
133
+ ) from None
134
+
135
+
136
+ def decrypt_text_for_org(org_id: str, value: str | bytes | None) -> str | None:
137
+ """decrypt_for_org, decoded as utf-8 (T2 columns are TEXT)."""
138
+ out = decrypt_for_org(org_id, value)
139
+ return None if out is None else out.decode("utf-8")
atp/db.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DB access layer β€” SQLAlchemy Core engine, tiny SQL helpers, migration runner.
3
+
4
+ One engine per process (module-level singleton), configured by env:
5
+
6
+ DATABASE_URL β€” full SQLAlchemy URL. Unset β†’ SQLite (see below).
7
+ Postgres: postgresql+psycopg2://user:pass@host:5432/db
8
+ (bare postgres:// / postgresql:// URLs, as handed out by
9
+ Render/Heroku, are normalized to the psycopg2 driver).
10
+ BRAIN_DB β€” SQLite path used when DATABASE_URL is unset
11
+ (default data/brain_university.db; parent dir created).
12
+ BU_DB_POOL_SIZE β€” Postgres pool size (default 5; max_overflow fixed at 5).
13
+
14
+ Public:
15
+ get_engine() -> Engine lazy singleton
16
+ is_postgres() -> bool True when the engine talks to Postgres
17
+ query(sql, params) -> list[dict] SELECTs; named params (:name), both dialects
18
+ execute(sql, params) -> ExecResult (.rowcount, .lastrowid)
19
+ run_migrations() -> list[str] apply pending migrations/ files (idempotent)
20
+ reset_engine() dispose the singleton (tests / env changes)
21
+
22
+ Writing SQL that works on BOTH dialects:
23
+ * Bind values with SQLAlchemy named params (:name) β€” never string-format
24
+ values into SQL. `query`/`execute` wrap the SQL in sqlalchemy.text().
25
+ * For INSERT ids, append `RETURNING <id_col>` β€” both Postgres and SQLite
26
+ >= 3.35 (Python 3.11's bundled build) support it, and `execute()` surfaces
27
+ the first returned column as `.lastrowid`. Plain INSERTs also populate
28
+ `.lastrowid` on SQLite; on Postgres use RETURNING.
29
+
30
+ Migrations live in migrations/ as `NNN_name[.pg|.sqlite].sql`. For a given
31
+ base name (`NNN_name`) the file suffixed with the current dialect wins over
32
+ the plain `.sql` one. Applied base names are tracked in `schema_migrations`;
33
+ each file is applied inside a single transaction, so a failed migration
34
+ leaves the DB untouched (Postgres; SQLite DDL is best-effort transactional).
35
+
36
+ Statement-separator convention (also documented at the top of each migration
37
+ file): a line containing only `--;;` splits the file into statements β€” needed
38
+ because trigger bodies contain ';'. Files without any `--;;` line are split
39
+ on plain ';'.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import re
46
+ import time
47
+ from pathlib import Path
48
+ from typing import NamedTuple
49
+
50
+ from sqlalchemy import create_engine, text
51
+ from sqlalchemy.engine import Engine, make_url
52
+ from sqlalchemy.pool import NullPool
53
+
54
+ PROJECT_ROOT = Path(__file__).parent.parent
55
+ MIGRATIONS_DIR = PROJECT_ROOT / "migrations"
56
+ DEFAULT_SQLITE_DB = PROJECT_ROOT / "data" / "brain_university.db"
57
+
58
+ STATEMENT_MARKER = "--;;"
59
+
60
+ _ENGINE: Engine | None = None
61
+
62
+
63
+ class ExecResult(NamedTuple):
64
+ """Outcome of a write statement.
65
+
66
+ rowcount β€” rows affected (UPDATE/DELETE; driver-dependent for INSERT).
67
+ lastrowid β€” SQLite: rowid of the last INSERT. Postgres: only populated
68
+ when the statement uses RETURNING (first column of the first
69
+ returned row) β€” RETURNING also works on SQLite >= 3.35, so
70
+ `INSERT ... RETURNING id` is the portable way to get new ids.
71
+ """
72
+
73
+ rowcount: int
74
+ lastrowid: int | None
75
+
76
+
77
+ # ── Engine ──────────────────────────────────────────────────────────────────
78
+
79
+ def _database_url() -> str:
80
+ url = os.environ.get("DATABASE_URL", "").strip()
81
+ if url:
82
+ # Render/Heroku hand out postgres:// β€” SQLAlchemy 2.x needs the
83
+ # explicit driver segment.
84
+ if url.startswith("postgres://"):
85
+ url = "postgresql+psycopg2://" + url[len("postgres://"):]
86
+ elif url.startswith("postgresql://"):
87
+ url = "postgresql+psycopg2://" + url[len("postgresql://"):]
88
+ return url
89
+ # No DATABASE_URL β†’ SQLite. Honour BRAIN_DB (already set by Dockerfile /
90
+ # render.yaml) so deployments keep pointing at the same file as before.
91
+ path = Path(os.environ.get("BRAIN_DB") or DEFAULT_SQLITE_DB)
92
+ return f"sqlite:///{path}"
93
+
94
+
95
+ def get_engine() -> Engine:
96
+ """Return the process-wide engine, creating it on first use."""
97
+ global _ENGINE
98
+ if _ENGINE is None:
99
+ url = make_url(_database_url())
100
+ if url.get_backend_name() == "sqlite":
101
+ if url.database and url.database != ":memory:":
102
+ Path(url.database).parent.mkdir(parents=True, exist_ok=True)
103
+ # NullPool + check_same_thread=False: FastAPI serves requests on
104
+ # a thread pool; a fresh short-lived connection per operation is
105
+ # the safe pattern for SQLite.
106
+ _ENGINE = create_engine(
107
+ url,
108
+ poolclass=NullPool,
109
+ connect_args={"check_same_thread": False},
110
+ )
111
+ else:
112
+ _ENGINE = create_engine(
113
+ url,
114
+ pool_pre_ping=True, # survive dropped/idle-closed connections
115
+ pool_size=int(os.environ.get("BU_DB_POOL_SIZE", "5")),
116
+ max_overflow=5,
117
+ )
118
+ return _ENGINE
119
+
120
+
121
+ def is_postgres() -> bool:
122
+ return get_engine().dialect.name == "postgresql"
123
+
124
+
125
+ def reset_engine() -> None:
126
+ """Dispose the singleton so the next call rebuilds it from env (tests)."""
127
+ global _ENGINE
128
+ if _ENGINE is not None:
129
+ _ENGINE.dispose()
130
+ _ENGINE = None
131
+
132
+
133
+ # ── SQL helpers ─────────────────────────────────────────────────────────────
134
+
135
+ def query(sql: str, params: dict | None = None) -> list[dict]:
136
+ """Run a SELECT (named params `:name`) and return rows as list[dict]."""
137
+ with get_engine().connect() as conn:
138
+ rows = conn.execute(text(sql), params or {}).mappings().all()
139
+ return [dict(r) for r in rows]
140
+
141
+
142
+ def execute(sql: str, params: dict | None = None) -> ExecResult:
143
+ """Run a write statement in its own transaction. See ExecResult."""
144
+ with get_engine().begin() as conn:
145
+ result = conn.execute(text(sql), params or {})
146
+ lastrowid: int | None = None
147
+ if result.returns_rows: # INSERT ... RETURNING <id> (both dialects)
148
+ row = result.first()
149
+ if row is not None:
150
+ lastrowid = row[0]
151
+ else:
152
+ try:
153
+ lastrowid = result.lastrowid # meaningful on SQLite
154
+ except Exception: # noqa: BLE001 β€” drivers without lastrowid
155
+ lastrowid = None
156
+ return ExecResult(rowcount=result.rowcount, lastrowid=lastrowid)
157
+
158
+
159
+ # ── Migrations ──────────────────────────────────────────────────────────────
160
+
161
+ # NNN_name.sql | NNN_name.pg.sql | NNN_name.sqlite.sql
162
+ _MIGRATION_RE = re.compile(r"^(?P<base>\d+_\w+?)(?:\.(?P<dialect>pg|sqlite))?\.sql$")
163
+
164
+
165
+ def run_migrations() -> list[str]:
166
+ """Apply every unapplied migration file, in order. Returns applied names.
167
+
168
+ Idempotent: applied base names are recorded in schema_migrations, so a
169
+ second call is a no-op. Each migration runs in a single transaction
170
+ together with its schema_migrations bookkeeping row.
171
+ """
172
+ engine = get_engine()
173
+ with engine.begin() as conn:
174
+ conn.exec_driver_sql(
175
+ "CREATE TABLE IF NOT EXISTS schema_migrations ("
176
+ " name TEXT PRIMARY KEY,"
177
+ " applied_at DOUBLE PRECISION NOT NULL"
178
+ ")"
179
+ )
180
+ already = {r["name"] for r in query("SELECT name FROM schema_migrations")}
181
+
182
+ applied: list[str] = []
183
+ for base, path in _discover_migrations():
184
+ if base in already:
185
+ continue
186
+ statements = _split_statements(path.read_text())
187
+ with engine.begin() as conn:
188
+ # Raw DBAPI cursor, no parameters: exec_driver_sql() hands the
189
+ # driver an (empty) params object, which makes psycopg2 run its
190
+ # %-interpolation over the SQL β€” any literal '%' in a migration
191
+ # (e.g. plpgsql RAISE '%' placeholders in 002_atp.pg.sql) then
192
+ # raises TypeError. cursor.execute(sql) with no params skips
193
+ # interpolation entirely, on both drivers.
194
+ cursor = conn.connection.cursor()
195
+ try:
196
+ for stmt in statements:
197
+ cursor.execute(stmt)
198
+ finally:
199
+ cursor.close()
200
+ conn.execute(
201
+ text("INSERT INTO schema_migrations (name, applied_at)"
202
+ " VALUES (:name, :ts)"),
203
+ {"name": base, "ts": time.time()},
204
+ )
205
+ applied.append(base)
206
+ return applied
207
+
208
+
209
+ def _discover_migrations() -> list[tuple[str, Path]]:
210
+ """Return [(base_name, path)] sorted by base name.
211
+
212
+ For each base name, the file suffixed with the current dialect
213
+ (.pg.sql / .sqlite.sql) is preferred over the shared .sql one. A base
214
+ that only ships the OTHER dialect's file is an error β€” migrations must
215
+ cover whichever dialect the deployment runs on.
216
+ """
217
+ if not MIGRATIONS_DIR.is_dir():
218
+ return []
219
+ dialect = "pg" if is_postgres() else "sqlite"
220
+ by_base: dict[str, dict[str | None, Path]] = {}
221
+ for p in MIGRATIONS_DIR.iterdir():
222
+ m = _MIGRATION_RE.match(p.name)
223
+ if not m:
224
+ continue
225
+ by_base.setdefault(m.group("base"), {})[m.group("dialect")] = p
226
+
227
+ out: list[tuple[str, Path]] = []
228
+ for base in sorted(by_base):
229
+ files = by_base[base]
230
+ path = files.get(dialect) or files.get(None)
231
+ if path is None:
232
+ raise RuntimeError(
233
+ f"migration {base!r} has no file for dialect {dialect!r} "
234
+ f"(found: {sorted(p.name for p in files.values())})"
235
+ )
236
+ out.append((base, path))
237
+ return out
238
+
239
+
240
+ def _split_statements(sql: str) -> list[str]:
241
+ """Split migration SQL into individual statements.
242
+
243
+ If the file contains any line that is exactly `--;;`, those lines are the
244
+ only separators (lets trigger/function bodies keep their internal ';').
245
+ Otherwise the file is split on top-level ';' β€” ignoring ';' inside `--`
246
+ line comments, `/* */` block comments, and 'quoted strings'. Files with
247
+ BEGIN...END trigger bodies or $$-quoted function bodies MUST use the
248
+ marker. Chunks that are empty or comment-only are dropped.
249
+ """
250
+ lines = sql.splitlines()
251
+ if any(line.strip() == STATEMENT_MARKER for line in lines):
252
+ chunks: list[str] = []
253
+ current: list[str] = []
254
+ for line in lines:
255
+ if line.strip() == STATEMENT_MARKER:
256
+ chunks.append("\n".join(current))
257
+ current = []
258
+ else:
259
+ current.append(line)
260
+ chunks.append("\n".join(current))
261
+ else:
262
+ chunks = _split_on_semicolons(sql)
263
+
264
+ statements: list[str] = []
265
+ for chunk in chunks:
266
+ meaningful = "\n".join(
267
+ line for line in chunk.splitlines()
268
+ if line.strip() and not line.strip().startswith("--")
269
+ )
270
+ if meaningful.strip():
271
+ statements.append(chunk.strip())
272
+ return statements
273
+
274
+
275
+ def _split_on_semicolons(sql: str) -> list[str]:
276
+ """Split on ';' outside comments and string literals (both dialects)."""
277
+ chunks: list[str] = []
278
+ buf: list[str] = []
279
+ i, n = 0, len(sql)
280
+ while i < n:
281
+ two = sql[i:i + 2]
282
+ if two == "--": # line comment β€” runs to end of line
283
+ j = sql.find("\n", i)
284
+ j = n if j == -1 else j
285
+ buf.append(sql[i:j])
286
+ i = j
287
+ elif two == "/*": # block comment
288
+ j = sql.find("*/", i + 2)
289
+ j = n if j == -1 else j + 2
290
+ buf.append(sql[i:j])
291
+ i = j
292
+ elif sql[i] == "'": # string literal ('' is the escaped quote)
293
+ j = i + 1
294
+ while j < n:
295
+ if sql[j] == "'":
296
+ if sql[j + 1:j + 2] == "'":
297
+ j += 2
298
+ continue
299
+ break
300
+ j += 1
301
+ j = min(j + 1, n)
302
+ buf.append(sql[i:j])
303
+ i = j
304
+ elif sql[i] == ";":
305
+ chunks.append("".join(buf))
306
+ buf = []
307
+ i += 1
308
+ else:
309
+ buf.append(sql[i])
310
+ i += 1
311
+ chunks.append("".join(buf))
312
+ return chunks
atp/exams.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ atp/exams.py β€” Phase 3 certification engine (docs/HARDENING.md).
3
+
4
+ Turns the ATP standard's promises (docs/ATP.md Β§2) into enforced code:
5
+
6
+ blueprint β†’ item bank (atp/items/<cert_id>.json, human-editable)
7
+ β†’ candidate answers via agents.backend.get_backend(candidate_spec)
8
+ β†’ judge from a DIFFERENT model family β€” family_of(judge) !=
9
+ family_of(candidate) is checked BEFORE any model call, else
10
+ SeparationError (principle 2)
11
+ β†’ verdict vs the cert's passCriteria (overall + perSection)
12
+ β†’ evidence rows, append-only + HMAC-signed with a per-org
13
+ prev_hash chain via atp/signing.py (principles 1 & 3)
14
+ β†’ award row in atp_cert_awards.
15
+
16
+ Reproducibility semantics: every evidence payload records the cert's
17
+ passCriteria seed and temperature (0). Backends are expected to decode
18
+ deterministically (greedy/temperature-0 β€” the HF/local backends already pin
19
+ do_sample=False / temperature 0.0); a run's `runs` repeats exist to surface
20
+ any residual nondeterminism, and each repeat's judged score is kept in
21
+ run_scores with the item score as their mean. runs = min(passCriteria.runs, 3)
22
+ to keep real exams affordable.
23
+
24
+ Dry-run mode (CI): `dry_run=True` routes the judge to 'dryrun:judge' and a
25
+ missing candidate to 'dryrun:candidate' β€” both DryRunBackend, deterministic
26
+ pure functions of the prompts, mapped to distinct synthetic families so the
27
+ separation check passes. Same inputs β†’ same payloads β†’ same sigs (given the
28
+ same EVIDENCE_SIGNING_KEY and chain position).
29
+
30
+ All DB access goes through atp/tenant_db.py (org-scoped; RLS on Postgres).
31
+
32
+ Public:
33
+ run_exam(cert_id, candidate_spec=None, org_id='org-demo',
34
+ dry_run=False, agent_id=None) -> award dict
35
+ SeparationError / UnknownCertError / MissingItemBankError
36
+
37
+ CLI:
38
+ BU_AUTH_DISABLED=1 python3 -m atp.exams atp-l4-math --dry-run --verify-chain
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import os
45
+ import threading
46
+ import time
47
+ from pathlib import Path
48
+
49
+ from agents.backend import family_of, get_backend
50
+ from atp import db, signing, store, tenant_db
51
+
52
+ ITEMS_DIR = Path(__file__).parent / "items"
53
+ HARNESS_VERSION = "atp-exams@2026.1-p3"
54
+ DEFAULT_JUDGE_SPEC = "claude-haiku-4-5" # env ATP_JUDGE_SPEC overrides
55
+ MAX_RUNS = 3 # cap on passCriteria.runs per item
56
+
57
+ _ITEM_KEYS = ("id", "section", "type", "prompt", "expected", "points")
58
+
59
+ _MIGRATED = False
60
+ _MIGRATE_LOCK = threading.Lock()
61
+
62
+
63
+ class SeparationError(RuntimeError):
64
+ """Judge and candidate resolve to the same model family (principle 2)."""
65
+
66
+
67
+ class UnknownCertError(ValueError):
68
+ """cert_id does not exist in the seed (atp/seed.py CERTS)."""
69
+
70
+
71
+ class MissingItemBankError(FileNotFoundError):
72
+ """No usable item bank for the cert β€” blueprints-before-commissioning."""
73
+
74
+
75
+ def _now() -> str:
76
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
77
+
78
+
79
+ def _ensure_db() -> None:
80
+ """Idempotently apply migrations so a fresh SQLite file just works."""
81
+ global _MIGRATED
82
+ if _MIGRATED:
83
+ return
84
+ with _MIGRATE_LOCK:
85
+ if not _MIGRATED:
86
+ db.run_migrations()
87
+ _MIGRATED = True
88
+
89
+
90
+ # ── Cert + item bank loading ─────────────────────────────────────────────────
91
+
92
+ #: Phase 6 extension point (docs/HARDENING.md β€” first-customer UX).
93
+ #: Cert refs of the form f"{ORG_CERT_PREFIX}<draft_id>" resolve through
94
+ #: atp/knowledge.py: SME-approved company-knowledge drafts whose item banks
95
+ #: are T2 (encrypted at rest, docs/TENANCY.md) and are therefore materialized
96
+ #: IN MEMORY β€” never written under atp/items/ or any shared directory.
97
+ #: Plain cert ids keep the pre-Phase-6 seed + items-file behavior unchanged;
98
+ #: callers that don't pass org_id (or deployments without atp/knowledge.py)
99
+ #: are byte-for-byte the old path. Must equal knowledge.EXAM_CERT_PREFIX.
100
+ ORG_CERT_PREFIX = "org-l5-draft:"
101
+
102
+
103
+ def _org_bank(cert_id: str, org_id: str | None) -> dict | None:
104
+ """Resolve an org-scoped draft bank, or None for the classic path."""
105
+ if not org_id or not cert_id.startswith(ORG_CERT_PREFIX):
106
+ return None
107
+ try:
108
+ from atp import knowledge # lazy β€” optional Phase 6 module
109
+ except ImportError: # pragma: no cover β€” knowledge not deployed
110
+ return None
111
+ return knowledge.load_org_item_bank(org_id, cert_id)
112
+
113
+
114
+ def _load_cert(cert_id: str, org_id: str | None = None) -> dict:
115
+ org_bank = _org_bank(cert_id, org_id)
116
+ if org_bank is not None:
117
+ return org_bank["cert"]
118
+ if cert_id.startswith(ORG_CERT_PREFIX):
119
+ # Same error whether the draft is unknown, unapproved, or belongs to
120
+ # another org β€” no cross-tenant existence oracle (TENANCY.md #6).
121
+ raise UnknownCertError(
122
+ f"unknown cert {cert_id!r} β€” no SME-approved company-knowledge "
123
+ f"draft with this ref in the caller's org (atp/knowledge.py)"
124
+ )
125
+ for cert in store.get_data()["CERTS"]:
126
+ if cert["id"] == cert_id:
127
+ return cert
128
+ raise UnknownCertError(
129
+ f"unknown cert {cert_id!r} β€” certs are defined in atp/seed.py CERTS"
130
+ )
131
+
132
+
133
+ def _load_item_bank(cert: dict, org_id: str | None = None) -> list[dict]:
134
+ """Load + validate the cert's item bank against its blueprint.
135
+
136
+ Classic path: atp/items/<cert_id>.json. Phase 6 org path (ORG_CERT_PREFIX
137
+ refs + org_id): the in-memory bank from atp/knowledge.py β€” same
138
+ validation, same errors.
139
+ """
140
+ cert_id = cert["id"]
141
+ org_bank = _org_bank(cert_id, org_id)
142
+ if org_bank is not None:
143
+ source = f"org bank {cert_id}"
144
+ items = org_bank.get("items") or []
145
+ if not items:
146
+ raise MissingItemBankError(f"item bank {source} has an empty 'items' list")
147
+ else:
148
+ path = ITEMS_DIR / f"{cert_id}.json"
149
+ if not path.is_file():
150
+ raise MissingItemBankError(
151
+ f"no item bank at {path} β€” a cert cannot be examined before its "
152
+ f"blueprint has a bank (blueprints-before-commissioning, "
153
+ f"docs/ATP.md Β§7). See atp/items/README.md."
154
+ )
155
+ bank = json.loads(path.read_text(encoding="utf-8"))
156
+ items = bank.get("items") or []
157
+ if not items:
158
+ raise MissingItemBankError(f"item bank {path} has an empty 'items' list")
159
+ source = str(path)
160
+
161
+ section_names = {s["name"] for s in cert["blueprint"]["sections"]}
162
+ for item in items:
163
+ missing = [k for k in _ITEM_KEYS if k not in item]
164
+ if missing:
165
+ raise ValueError(
166
+ f"item bank {source}: item {item.get('id', '?')!r} is missing "
167
+ f"key(s) {missing} (format: atp/items/README.md)"
168
+ )
169
+ if item["section"] not in section_names:
170
+ raise ValueError(
171
+ f"item bank {source}: item {item['id']!r} names unknown section "
172
+ f"{item['section']!r} β€” blueprint sections are "
173
+ f"{sorted(section_names)}"
174
+ )
175
+ uncovered = section_names - {i["section"] for i in items}
176
+ if uncovered:
177
+ raise ValueError(
178
+ f"item bank {source} has no items for blueprint section(s) "
179
+ f"{sorted(uncovered)} β€” passCriteria.perSection would be "
180
+ f"unverifiable"
181
+ )
182
+ return items
183
+
184
+
185
+ # ── Judge prompt + defensive parsing ─────────────────────────────────────────
186
+
187
+ _JUDGE_SYSTEM = (
188
+ "You are the certification judge for the ATP (Agentic Training Platform) "
189
+ "exam harness. Grade the candidate's answer strictly against the "
190
+ "reference answer/criteria and the rubric. Respond with ONLY a strict "
191
+ "JSON object β€” no prose, no code fences: "
192
+ '{"score": <float between 0 and 1>, "rationale": "<one short sentence>"}'
193
+ )
194
+ _JUDGE_SYSTEM_RETRY = (
195
+ _JUDGE_SYSTEM
196
+ + " Your previous reply was not parseable JSON. Output the JSON object "
197
+ "and NOTHING else."
198
+ )
199
+
200
+
201
+ def _judge_user(cert: dict, item: dict, answer: str) -> str:
202
+ rubric = "; ".join(
203
+ f"{r['axis']} (weight {r['weight']}): {r['desc']}"
204
+ for r in cert.get("rubric", [])
205
+ )
206
+ return (
207
+ f"Exam: {cert['label']}\n"
208
+ f"Rubric: {rubric}\n\n"
209
+ f"Question [{item['section']} / {item['type']}]:\n{item['prompt']}\n\n"
210
+ f"Reference answer / grading criteria (not shown to candidate):\n"
211
+ f"{item['expected']}\n\n"
212
+ f"Candidate answer:\n{answer}\n\n"
213
+ 'Return strict JSON only: {"score": <float 0..1>, "rationale": "<one sentence>"}'
214
+ )
215
+
216
+
217
+ def _parse_judge(raw: str) -> dict | None:
218
+ """Defensive parse: first '{' .. last '}', score must be a number in 0..1."""
219
+ try:
220
+ start = raw.index("{")
221
+ end = raw.rindex("}") + 1
222
+ obj = json.loads(raw[start:end])
223
+ score = float(obj["score"])
224
+ except (ValueError, KeyError, TypeError, AttributeError):
225
+ return None
226
+ if not (0.0 <= score <= 1.0):
227
+ return None
228
+ return {"score": score, "rationale": str(obj.get("rationale", ""))}
229
+
230
+
231
+ # ── Evidence writing (signed, chained, org-scoped) ───────────────────────────
232
+ # All evidence rows for a run are accumulated in memory during the (slow)
233
+ # model-call loop and appended in ONE signing.append_chain() call β€” a single
234
+ # serialized transaction, so concurrent runs can't interleave the chain and
235
+ # a crash mid-run leaves no partially signed exam on disk.
236
+
237
+
238
+ # ── The exam loop ────────────────────────────────────────────────────────────
239
+
240
+ def run_exam(cert_id: str, candidate_spec: str | None = None,
241
+ org_id: str = tenant_db.DEFAULT_ORG, dry_run: bool = False,
242
+ agent_id: str | None = None) -> dict:
243
+ """Run a full certification exam and return the award dict.
244
+
245
+ Raises UnknownCertError / MissingItemBankError / SeparationError before
246
+ any model call; SigningKeyError surfaces from atp/signing.py if evidence
247
+ cannot be signed.
248
+ """
249
+ # org_id also feeds the Phase 6 loader path (ORG_CERT_PREFIX refs only;
250
+ # seed cert ids are unaffected by the extra argument).
251
+ cert = _load_cert(cert_id, org_id)
252
+ items = _load_item_bank(cert, org_id)
253
+
254
+ candidate = candidate_spec or "dryrun:candidate"
255
+ judge = "dryrun:judge" if dry_run else os.environ.get(
256
+ "ATP_JUDGE_SPEC", DEFAULT_JUDGE_SPEC)
257
+
258
+ # Principle 2 β€” judge/candidate separation, enforced BEFORE any model call.
259
+ if family_of(judge) == family_of(candidate):
260
+ raise SeparationError(
261
+ f"judge/candidate separation violated: judge {judge!r} and "
262
+ f"candidate {candidate!r} both resolve to model family "
263
+ f"{family_of(judge)!r} (docs/ATP.md Β§2 principle 2). Set "
264
+ f"ATP_JUDGE_SPEC to a different family, or pass a different "
265
+ f"candidate."
266
+ )
267
+
268
+ pc = cert["passCriteria"]
269
+ runs = max(1, min(int(pc.get("runs", 1)), MAX_RUNS))
270
+ seed = int(pc.get("seed", 0))
271
+ temperature = pc.get("temperature", 0)
272
+
273
+ _ensure_db()
274
+ candidate_backend = get_backend(candidate)
275
+ judge_backend = get_backend(judge)
276
+
277
+ candidate_system = (
278
+ f"You are sitting the {cert['label']} certification exam "
279
+ f"({cert['domain']}, layer L{cert['layer']}). Answer each question "
280
+ f"precisely and show your work. Reproducibility: decoding is pinned "
281
+ f"to temperature {temperature}, seed {seed} "
282
+ f"(harness {HARNESS_VERSION})."
283
+ )
284
+
285
+ agent = agent_id or candidate
286
+ ev_records: list[dict] = [] # appended to the chain in one txn
287
+ graded: list[tuple[dict, list[float], bool]] = []
288
+
289
+ for item in items:
290
+ run_scores: list[float] = []
291
+ flagged = False
292
+ for _ in range(runs):
293
+ answer = candidate_backend.complete(
294
+ candidate_system, item["prompt"], max_tokens=800)
295
+ judge_prompt = _judge_user(cert, item, answer)
296
+ verdict = _parse_judge(
297
+ judge_backend.complete(_JUDGE_SYSTEM, judge_prompt,
298
+ max_tokens=300))
299
+ if verdict is None: # retry once, stricter instruction
300
+ verdict = _parse_judge(
301
+ judge_backend.complete(_JUDGE_SYSTEM_RETRY, judge_prompt,
302
+ max_tokens=300))
303
+ if verdict is None: # still unparseable β†’ item flagged
304
+ flagged = True
305
+ run_scores.append(0.0)
306
+ else:
307
+ run_scores.append(round(verdict["score"], 4))
308
+
309
+ payload = {
310
+ "certId": cert_id,
311
+ "itemId": item["id"],
312
+ "run_scores": run_scores,
313
+ "judge": judge,
314
+ "candidate": candidate,
315
+ "seed": seed,
316
+ "temperature": temperature,
317
+ }
318
+ ev_records.append({"ts": _now(), "agent_id": agent,
319
+ "cert_id": cert_id, "kind": "exam_item",
320
+ "payload": payload})
321
+ graded.append((item, run_scores, flagged))
322
+
323
+ # ── Aggregate: points-weighted per section, blueprint-weighted overall ──
324
+ item_bar = float(pc.get("perSection", 0.7)) # per-item pass bar
325
+ per_section: dict[str, list[tuple[float, float]]] = {}
326
+ breakdown = {"passed": 0, "failed": 0, "flagged": 0}
327
+ for item, run_scores, flagged in graded:
328
+ mean = sum(run_scores) / len(run_scores)
329
+ per_section.setdefault(item["section"], []).append(
330
+ (mean, float(item["points"])))
331
+ if flagged:
332
+ breakdown["flagged"] += 1
333
+ elif mean >= item_bar:
334
+ breakdown["passed"] += 1
335
+ else:
336
+ breakdown["failed"] += 1
337
+
338
+ section_scores = {
339
+ name: round(sum(s * p for s, p in vals) / sum(p for _, p in vals), 4)
340
+ for name, vals in per_section.items()
341
+ }
342
+ weights = {s["name"]: float(s["weight"])
343
+ for s in cert["blueprint"]["sections"]}
344
+ weight_sum = sum(weights[name] for name in section_scores)
345
+ overall = round(
346
+ sum(section_scores[name] * weights[name] for name in section_scores)
347
+ / weight_sum, 4)
348
+
349
+ verdict_str = (
350
+ "pass"
351
+ if overall >= float(pc["overall"])
352
+ and all(v >= float(pc["perSection"]) for v in section_scores.values())
353
+ and breakdown["flagged"] == 0
354
+ else "fail"
355
+ )
356
+
357
+ # ── Summary evidence row (kind=benchmark_run) ───────────────────────────
358
+ summary_payload = {
359
+ "certId": cert_id,
360
+ "overall": overall,
361
+ "sectionScores": section_scores,
362
+ "verdict": verdict_str,
363
+ "itemBreakdown": breakdown,
364
+ "judge": judge,
365
+ "candidate": candidate,
366
+ "seed": seed,
367
+ "temperature": temperature,
368
+ "runs": runs,
369
+ "harness": HARNESS_VERSION,
370
+ }
371
+ ev_records.append({"ts": _now(), "agent_id": agent, "cert_id": cert_id,
372
+ "kind": "benchmark_run", "payload": summary_payload})
373
+
374
+ # One serialized, atomic chain append for the whole run.
375
+ evidence_ids = [row_id for row_id, _sig in
376
+ signing.append_chain(org_id, ev_records)]
377
+
378
+ # ── Award row ───────────────────────────────────────────────────────────
379
+ ts = _now()
380
+ res = tenant_db.insert_scoped("atp_cert_awards", {
381
+ "ts": ts,
382
+ "agent_id": agent,
383
+ "cert_id": cert_id,
384
+ "score": overall,
385
+ "section_scores": json.dumps(section_scores, sort_keys=True),
386
+ "item_breakdown": json.dumps(breakdown, sort_keys=True),
387
+ "evidence_ids": json.dumps(evidence_ids),
388
+ }, org_id)
389
+
390
+ return {
391
+ "agentId": agent,
392
+ "certId": cert_id,
393
+ "ts": ts,
394
+ "score": overall,
395
+ "verdict": verdict_str,
396
+ "sectionScores": section_scores,
397
+ "itemBreakdown": breakdown,
398
+ "evidenceIds": evidence_ids,
399
+ "judge": judge,
400
+ "candidate": candidate,
401
+ "runs": runs,
402
+ "seed": seed,
403
+ "temperature": temperature,
404
+ "harness": HARNESS_VERSION,
405
+ "awardRowId": res.lastrowid,
406
+ "dryRun": bool(dry_run),
407
+ }
408
+
409
+
410
+ # ── CLI ──────────────────────────────────────────────────────────────────────
411
+
412
+ if __name__ == "__main__":
413
+ import argparse
414
+
415
+ parser = argparse.ArgumentParser(
416
+ description="Run an ATP certification exam (Phase 3 engine).")
417
+ parser.add_argument("cert_id", help="e.g. atp-l4-math")
418
+ parser.add_argument("--candidate", default=None,
419
+ help="candidate backend spec (default: dryrun:candidate)")
420
+ parser.add_argument("--org", default=tenant_db.DEFAULT_ORG)
421
+ parser.add_argument("--agent-id", default=None,
422
+ help="agent id recorded on evidence/award rows "
423
+ "(default: the candidate spec)")
424
+ parser.add_argument("--dry-run", action="store_true",
425
+ help="deterministic stub judge/candidate β€” no model "
426
+ "calls, no cost (CI mode)")
427
+ parser.add_argument("--verify-chain", action="store_true",
428
+ help="verify the org's evidence chain after the run")
429
+ args = parser.parse_args()
430
+
431
+ award = run_exam(args.cert_id, args.candidate, org_id=args.org,
432
+ dry_run=args.dry_run, agent_id=args.agent_id)
433
+ print(json.dumps(award, indent=2))
434
+ if args.verify_chain:
435
+ print(json.dumps(signing.verify_chain(args.org), indent=2))
atp/items/README.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ATP item banks β€” `atp/items/*.json`
2
+
3
+ One JSON file per certification, named exactly after the cert id in
4
+ `atp/seed.py` (e.g. `atp-l4-math.json` for cert `atp-l4-math`). These are the
5
+ concrete exam questions behind each cert's blueprint. They are **generated +
6
+ human-editable**: SMEs edit these files directly; no build step.
7
+
8
+ **Blueprints-before-commissioning invariant** (docs/ATP.md Β§7): an exam can
9
+ only run for a cert whose item bank exists β€” `atp/exams.py` raises
10
+ `MissingItemBankError` otherwise. Adding a cert to the seed without adding its
11
+ bank here means it cannot be examined, by design.
12
+
13
+ ## File format
14
+
15
+ ```json
16
+ {
17
+ "certId": "atp-l4-math", // must equal the filename + seed cert id
18
+ "label": "ATP-L4.math@2026.1", // cosmetic, copied from the cert
19
+ "version": "2026.1", // bump when the bank changes materially
20
+ "notes": "free-text for SMEs",
21
+ "items": [ ... ] // the bank β€” see below
22
+ }
23
+ ```
24
+
25
+ Each item:
26
+
27
+ | key | type | meaning |
28
+ |------------|--------|---------|
29
+ | `id` | str | stable unique id, `<cert-shorthand>-<sec>-NN` (e.g. `l4m-sym-01`). Never reuse or renumber a shipped id β€” evidence rows reference it forever. |
30
+ | `section` | str | MUST match one of the cert blueprint's `sections[].name` in `atp/seed.py` **verbatim** (copy-paste it). Unknown sections fail exam load. |
31
+ | `type` | str | one of `recall` \| `apply` \| `synthesize` \| `adversarial`, and it should be one of that section's `itemTypes` in the blueprint. |
32
+ | `prompt` | str | the question exactly as shown to the candidate model. Self-contained: include any passage/data inline. |
33
+ | `expected` | str | reference answer **and/or grading criteria** for the judge model. State what full credit requires and name known-wrong answers where useful. Never shown to the candidate. |
34
+ | `points` | int | weight of the item *within its section* (section scores are points-weighted; sections are then combined by the blueprint's section weights). |
35
+
36
+ ## Validation at exam load (`atp/exams.py`)
37
+
38
+ * file exists and has a non-empty `items` list;
39
+ * every item carries all six keys;
40
+ * every item's `section` is a blueprint section of the cert;
41
+ * every blueprint section has at least one item (otherwise the
42
+ `passCriteria.perSection` bar would be unverifiable).
43
+
44
+ ## How SMEs edit
45
+
46
+ 1. Edit the cert's JSON in place (any editor; keep it valid JSON β€” trailing
47
+ commas are not).
48
+ 2. **Add** items freely (new ids, correct `section`/`type`); **retire** items
49
+ by deleting them β€” but never recycle a deleted id.
50
+ 3. Keep `expected` judge-ready: it is pasted into the judge prompt as the
51
+ reference. Write criteria ("full credit requires…"), not just an answer,
52
+ for synthesize/adversarial items.
53
+ 4. Adversarial items should contain a trap (flawed premise, tempting wrong
54
+ method) and `expected` must say what falling into the trap looks like.
55
+ 5. Bump `version`, then sanity-check with a dry run (no model calls, no cost):
56
+
57
+ ```bash
58
+ BU_AUTH_DISABLED=1 python3 -m atp.exams atp-l4-math --dry-run --verify-chain
59
+ ```
60
+
61
+ L5 (company-knowledge) banks additionally require SME sign-off on the cert
62
+ itself (`smeSignoff` in `atp/seed.py`) before awards are meaningful β€” the
63
+ bank alone is not enough (docs/ATP.md Β§1, L5 row).
atp/items/atp-l1-core.json ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "certId": "atp-l1-core",
3
+ "label": "ATP-L1.core@2026.1",
4
+ "version": "2026.1",
5
+ "notes": "Item bank for the L1 Core Skills cert (Exeter-modeled K-12 competencies). Sections MUST match the cert blueprint in atp/seed.py verbatim. See atp/items/README.md for the format and editing rules.",
6
+ "items": [
7
+ {
8
+ "id": "l1c-num-01",
9
+ "section": "Numeracy",
10
+ "type": "recall",
11
+ "prompt": "What is 7 x 8?",
12
+ "expected": "56. Exact answer required.",
13
+ "points": 1
14
+ },
15
+ {
16
+ "id": "l1c-num-02",
17
+ "section": "Numeracy",
18
+ "type": "apply",
19
+ "prompt": "Compute 3/4 + 1/6, giving the answer as a fraction in lowest terms. Show the common-denominator step.",
20
+ "expected": "11/12. Common denominator 12: 3/4 = 9/12 and 1/6 = 2/12, so 9/12 + 2/12 = 11/12 (already in lowest terms). The common-denominator step must be shown.",
21
+ "points": 2
22
+ },
23
+ {
24
+ "id": "l1c-num-03",
25
+ "section": "Numeracy",
26
+ "type": "apply",
27
+ "prompt": "A jacket costs $80 and is discounted by 15%. What is the sale price? Show the calculation.",
28
+ "expected": "$68. Discount is 0.15 x 80 = $12, so 80 - 12 = 68 (equivalently 0.85 x 80). The working must be shown, not just the final number.",
29
+ "points": 2
30
+ },
31
+ {
32
+ "id": "l1c-red-01",
33
+ "section": "Reading comprehension",
34
+ "type": "recall",
35
+ "prompt": "Passage: 'Honeybees tell hive-mates where food is through a waggle dance. The angle of the dance relative to vertical encodes the direction of the food relative to the sun, while the duration of the waggle encodes the distance.' Question: according to the passage, what does the DURATION of the waggle encode?",
36
+ "expected": "The distance to the food. (Direction is encoded by the angle, not the duration β€” answers citing direction are wrong.)",
37
+ "points": 1
38
+ },
39
+ {
40
+ "id": "l1c-red-02",
41
+ "section": "Reading comprehension",
42
+ "type": "apply",
43
+ "prompt": "Passage: 'Maria watered her ferns every morning, yet their leaves kept yellowing. When she finally moved the pots away from the bright south-facing window, the ferns recovered within two weeks β€” same watering schedule as before.' Question: what most likely caused the yellowing, and which detail in the passage supports that inference?",
44
+ "expected": "Too much direct sunlight (not under-watering): the ferns recovered when moved away from the bright window while the watering schedule stayed identical, so light exposure is the variable that changed. Both the inference and the supporting detail are required.",
45
+ "points": 2
46
+ },
47
+ {
48
+ "id": "l1c-red-03",
49
+ "section": "Reading comprehension",
50
+ "type": "apply",
51
+ "prompt": "Passage: 'The town library began opening on Sundays last year. Weekend visits doubled, late returns fell by a third, and β€” to the surprise of the council β€” weekday visits rose too, as families who discovered the library on Sundays started coming back midweek.' Question: state the main idea of the passage in one sentence.",
52
+ "expected": "Opening the library on Sundays improved usage overall β€” not just on weekends, but on weekdays as well. A main-idea answer must cover the spillover effect; restating only one statistic is insufficient.",
53
+ "points": 2
54
+ },
55
+ {
56
+ "id": "l1c-cmp-01",
57
+ "section": "Composition",
58
+ "type": "synthesize",
59
+ "prompt": "Write a paragraph of exactly three sentences arguing that schools should have vegetable gardens. Sentence 1 must state the claim, sentence 2 must give one concrete supporting reason, sentence 3 must conclude.",
60
+ "expected": "Grading criteria, not a fixed text: (1) exactly three sentences; (2) first sentence states the claim plainly; (3) second sentence gives one concrete, relevant reason (e.g. hands-on science learning, nutrition); (4) third sentence concludes without introducing a new argument. Grammatical, coherent prose.",
61
+ "points": 3
62
+ },
63
+ {
64
+ "id": "l1c-cmp-02",
65
+ "section": "Composition",
66
+ "type": "synthesize",
67
+ "prompt": "Rewrite this run-on sentence as correct English, preserving all of its information: 'The experiment failed we forgot to label the samples so we had to start over.'",
68
+ "expected": "Any grammatical fix that keeps all three facts, e.g. 'The experiment failed because we forgot to label the samples, so we had to start over.' or 'The experiment failed. We had forgotten to label the samples, so we had to start over.' Must eliminate the comma-splice/run-on and preserve failure, cause, and restart.",
69
+ "points": 2
70
+ },
71
+ {
72
+ "id": "l1c-sci-01",
73
+ "section": "Scientific & chronological reasoning",
74
+ "type": "apply",
75
+ "prompt": "A student wants to test whether a fertilizer makes bean plants grow taller. She plants 10 beans with fertilizer and 10 beans without, keeping light, water, soil, and pot size identical for all 20. Which group is the control, and why is it needed?",
76
+ "expected": "The 10 plants WITHOUT fertilizer are the control group. It is needed as the baseline: without it, any growth in the fertilized group could not be attributed to the fertilizer rather than to the shared conditions. Both identification and justification required.",
77
+ "points": 2
78
+ },
79
+ {
80
+ "id": "l1c-sci-02",
81
+ "section": "Scientific & chronological reasoning",
82
+ "type": "adversarial",
83
+ "prompt": "In summer, both ice-cream sales and drowning deaths rise sharply, and the two track each other closely all year. A report concludes that ice cream causes drowning. Explain the error in this conclusion and identify the more plausible explanation.",
84
+ "expected": "Correlation is not causation: both variables are driven by a confounding third factor β€” hot weather brings more swimming (hence more drownings) AND more ice-cream sales. The answer must name the confounder (season/heat) and reject the causal claim; simply saying 'correlation isn't causation' without the confounder is partial credit at best.",
85
+ "points": 3
86
+ },
87
+ {
88
+ "id": "l1c-sci-03",
89
+ "section": "Scientific & chronological reasoning",
90
+ "type": "apply",
91
+ "prompt": "Place these four events in chronological order, earliest first: the first telephone call (Bell), the moon landing, the printing press (Gutenberg), the first public telegraph line (Morse).",
92
+ "expected": "Printing press (c. 1440) -> telegraph (1844) -> telephone (1876) -> moon landing (1969). All four in the correct order required; exact years are not required but wreck nothing if given correctly.",
93
+ "points": 2
94
+ }
95
+ ]
96
+ }
atp/items/atp-l4-math.json ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "certId": "atp-l4-math",
3
+ "label": "ATP-L4.math@2026.1",
4
+ "version": "2026.1",
5
+ "notes": "Item bank for the L4 Math cert (world-knowledge engines: CAS interop). Sections MUST match the cert blueprint in atp/seed.py verbatim. See atp/items/README.md for the format and editing rules.",
6
+ "items": [
7
+ {
8
+ "id": "l4m-sym-01",
9
+ "section": "Symbolic manipulation (CAS interop)",
10
+ "type": "apply",
11
+ "prompt": "Expand (x + 2)^3 completely. Show the intermediate binomial steps.",
12
+ "expected": "x^3 + 6x^2 + 12x + 8. Full credit requires the binomial expansion steps (coefficients 1,3,3,1 with powers of 2) or an equivalent two-stage multiplication.",
13
+ "points": 2
14
+ },
15
+ {
16
+ "id": "l4m-sym-02",
17
+ "section": "Symbolic manipulation (CAS interop)",
18
+ "type": "apply",
19
+ "prompt": "Factor x^4 - 5x^2 + 4 completely over the integers.",
20
+ "expected": "(x - 1)(x + 1)(x - 2)(x + 2). Substitution u = x^2 gives (u - 1)(u - 4), then each difference of squares splits. All four linear factors required.",
21
+ "points": 2
22
+ },
23
+ {
24
+ "id": "l4m-sym-03",
25
+ "section": "Symbolic manipulation (CAS interop)",
26
+ "type": "apply",
27
+ "prompt": "Simplify the rational expression (x^2 - 9) / (x^2 - x - 6) and state every excluded value of x.",
28
+ "expected": "(x + 3)/(x + 2), with x != 3 and x != -2 excluded (zeros of the ORIGINAL denominator). Numerator factors as (x-3)(x+3), denominator as (x-3)(x+2). Both exclusions must be stated.",
29
+ "points": 3
30
+ },
31
+ {
32
+ "id": "l4m-sym-04",
33
+ "section": "Symbolic manipulation (CAS interop)",
34
+ "type": "apply",
35
+ "prompt": "Decompose (3x + 5) / ((x + 1)(x + 2)) into partial fractions.",
36
+ "expected": "2/(x + 1) + 1/(x + 2). Setting 3x + 5 = A(x + 2) + B(x + 1): x = -1 gives A = 2, x = -2 gives B = 1.",
37
+ "points": 3
38
+ },
39
+ {
40
+ "id": "l4m-sym-05",
41
+ "section": "Symbolic manipulation (CAS interop)",
42
+ "type": "apply",
43
+ "prompt": "Differentiate f(x) = x^2 sin(x) with respect to x, naming the rule used.",
44
+ "expected": "f'(x) = 2x sin(x) + x^2 cos(x), by the product rule. Both terms required; naming the product rule required for full rigor credit.",
45
+ "points": 2
46
+ },
47
+ {
48
+ "id": "l4m-prf-01",
49
+ "section": "Proof & synthesis",
50
+ "type": "synthesize",
51
+ "prompt": "Prove that sqrt(2) is irrational.",
52
+ "expected": "Proof by contradiction: assume sqrt(2) = p/q in lowest terms, so p^2 = 2q^2, hence p is even (p = 2k), then 4k^2 = 2q^2 gives q^2 = 2k^2 so q is also even β€” contradicting lowest terms. Must state the lowest-terms assumption and derive the parity contradiction; no skipped steps.",
53
+ "points": 4
54
+ },
55
+ {
56
+ "id": "l4m-prf-02",
57
+ "section": "Proof & synthesis",
58
+ "type": "synthesize",
59
+ "prompt": "Prove by mathematical induction that 1 + 3 + 5 + ... + (2n - 1) = n^2 for all integers n >= 1.",
60
+ "expected": "Base case n = 1: LHS = 1 = 1^2. Inductive step: assume sum to k terms = k^2; adding the next odd number (2k + 1) gives k^2 + 2k + 1 = (k + 1)^2. Both the base case and an explicitly stated inductive hypothesis are required.",
61
+ "points": 4
62
+ },
63
+ {
64
+ "id": "l4m-prf-03",
65
+ "section": "Proof & synthesis",
66
+ "type": "adversarial",
67
+ "prompt": "Claim: if f is differentiable and f'(c) = 0, then f has a local maximum or local minimum at c. Prove the claim or refute it.",
68
+ "expected": "The claim is FALSE. Counterexample: f(x) = x^3 at c = 0 has f'(0) = 0 but is strictly increasing, so x = 0 is neither a local max nor a local min (it is an inflection point). A single valid counterexample suffices; a 'proof' of the claim scores 0 on correctness.",
69
+ "points": 4
70
+ },
71
+ {
72
+ "id": "l4m-prf-04",
73
+ "section": "Proof & synthesis",
74
+ "type": "adversarial",
75
+ "prompt": "The following argument concludes 2 = 1. Identify the exact flawed step and explain why it is invalid. Let a = b. Then a^2 = ab; a^2 - b^2 = ab - b^2; (a - b)(a + b) = b(a - b); a + b = b; 2b = b; therefore 2 = 1.",
76
+ "expected": "The step dividing both sides by (a - b) is invalid: since a = b, a - b = 0, and division by zero is undefined. Every other step is legal. The answer must locate the cancellation of (a - b) specifically, not just say 'division by zero somewhere'.",
77
+ "points": 3
78
+ },
79
+ {
80
+ "id": "l4m-prf-05",
81
+ "section": "Proof & synthesis",
82
+ "type": "synthesize",
83
+ "prompt": "Prove that there are infinitely many prime numbers.",
84
+ "expected": "Euclid's argument: suppose p1..pk were all the primes; let N = p1*p2*...*pk + 1. N leaves remainder 1 on division by every pi, so any prime factor of N (which exists since N > 1) is a prime not in the list β€” contradiction. Must note N need not itself be prime, only that its prime factor is new.",
85
+ "points": 4
86
+ },
87
+ {
88
+ "id": "l4m-eng-01",
89
+ "section": "Engine tool-use (SymPy/CAS)",
90
+ "type": "apply",
91
+ "prompt": "Write the exact SymPy code to solve x^2 - 5x + 6 = 0 for x, and state the result the engine returns.",
92
+ "expected": "import sympy; x = sympy.symbols('x'); sympy.solve(x**2 - 5*x + 6, x) β€” returns [2, 3]. Code must be runnable (symbol declared, correct API) and the reported roots must be exactly 2 and 3.",
93
+ "points": 3
94
+ },
95
+ {
96
+ "id": "l4m-eng-02",
97
+ "section": "Engine tool-use (SymPy/CAS)",
98
+ "type": "apply",
99
+ "prompt": "Using SymPy, compute the indefinite integral of x*exp(x) with respect to x. Give the code and the engine's result.",
100
+ "expected": "sympy.integrate(x*sympy.exp(x), x) returns (x - 1)*exp(x). Answer must report the engine output faithfully β€” writing x*exp(x) - exp(x) is equivalent and acceptable; omitting the code or misreading the result is not.",
101
+ "points": 3
102
+ },
103
+ {
104
+ "id": "l4m-eng-03",
105
+ "section": "Engine tool-use (SymPy/CAS)",
106
+ "type": "apply",
107
+ "prompt": "Using SymPy, evaluate the limit of sin(x)/x as x approaches 0. Give the code and the result.",
108
+ "expected": "sympy.limit(sympy.sin(x)/x, x, 0) returns 1. Full credit requires the limit() call (not a numeric approximation) and the exact value 1.",
109
+ "points": 2
110
+ },
111
+ {
112
+ "id": "l4m-eng-04",
113
+ "section": "Engine tool-use (SymPy/CAS)",
114
+ "type": "apply",
115
+ "prompt": "Using SymPy's Matrix class, invert the matrix [[2, 1], [1, 1]]. Give the code and the exact inverse.",
116
+ "expected": "sympy.Matrix([[2, 1], [1, 1]]).inv() returns Matrix([[1, -1], [-1, 2]]) (determinant is 1). The exact integer inverse is required; a decimal/numeric inverse shows the engine was not used faithfully.",
117
+ "points": 3
118
+ },
119
+ {
120
+ "id": "l4m-num-01",
121
+ "section": "Numerical stability",
122
+ "type": "apply",
123
+ "prompt": "In float64 arithmetic, computing 1 - cos(x) directly for very small x (say x = 1e-8) loses almost all significant digits. Explain why, and give a numerically stable equivalent expression.",
124
+ "expected": "Catastrophic cancellation: cos(x) is approximately 1 - x^2/2, so 1 - cos(x) subtracts two nearly equal numbers and the leading digits cancel, leaving only rounding noise. Stable rewrite: 2*sin(x/2)^2 (exactly equal analytically, no subtraction of near-equal quantities).",
125
+ "points": 3
126
+ },
127
+ {
128
+ "id": "l4m-num-02",
129
+ "section": "Numerical stability",
130
+ "type": "adversarial",
131
+ "prompt": "Solving x^2 - 1e8*x + 1 = 0 with the textbook quadratic formula in float64 returns 0.0 for the small root. Diagnose the failure and give a stable procedure that recovers both roots accurately.",
132
+ "expected": "For the small root the formula computes (1e8 - sqrt(1e16 - 4))/2: sqrt(1e16 - 4) rounds to 1e8 in float64, so the subtraction cancels catastrophically to 0. Stable: compute the large root x1 = (-b - sign(b)*sqrt(b^2 - 4ac)) / (2a) (no cancellation), then the small root via Vieta: x2 = c/(a*x1) = 1e-8. Both the diagnosis (cancellation in the subtraction) and the Vieta rescue are required.",
133
+ "points": 4
134
+ },
135
+ {
136
+ "id": "l4m-num-03",
137
+ "section": "Numerical stability",
138
+ "type": "adversarial",
139
+ "prompt": "True or false: IEEE-754 float64 addition is associative. Justify with a concrete counterexample evaluated in float64.",
140
+ "expected": "False. Counterexample: (1e16 + 1.0) - 1e16 evaluates to 0.0 because 1e16 + 1.0 rounds back to 1e16 (the spacing between adjacent float64 values at 1e16 is 2), while 1e16 - 1e16 + 1.0 evaluates to 1.0. Any counterexample with correct float64 rounding behaviour is acceptable.",
141
+ "points": 3
142
+ },
143
+ {
144
+ "id": "l4m-num-04",
145
+ "section": "Numerical stability",
146
+ "type": "apply",
147
+ "prompt": "Naive Gaussian elimination WITHOUT pivoting fails badly on the system [[1e-20, 1], [1, 1]] * [x, y] = [1, 2] in float64. Explain the mechanism and name the standard fix.",
148
+ "expected": "Using the tiny pivot 1e-20 produces the multiplier 1e20; the row update computes 1 - 1e20 which rounds to -1e20, wiping out the original coefficients (the true data is absorbed into rounding error), yielding x = 0 instead of x approximately 1. Fix: partial pivoting β€” swap rows so the largest-magnitude entry in the column is the pivot, keeping multipliers bounded by 1.",
149
+ "points": 3
150
+ }
151
+ ]
152
+ }
atp/knowledge.py ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Company-knowledge ingestion + L5 exam drafting β€” Phase 6 (docs/HARDENING.md).
3
+
4
+ Pipeline (first-customer UX contract):
5
+
6
+ upload (md/txt/pdf) ──> vault_docs row [T2: content encrypted]
7
+ β”‚
8
+ └──> generate_l5_draft() [deterministic heuristic
9
+ blueprint + >=8 items β€” NO LLM dependency]
10
+ β”‚
11
+ SME sign-off ────┴──> approve_draft() ['sme_signoff' evidence
12
+ β”‚ on the signed chain]
13
+ └──> load_org_item_bank() [in-memory bank consumed
14
+ by atp/exams.py via its Phase 6 loader
15
+ ORG_CERT_PREFIX path] -> run_exam -> award
16
+
17
+ Tenancy & data class (docs/TENANCY.md β€” T2 "crown jewels"):
18
+
19
+ * Every row is org-scoped; all SQL here goes through atp/tenant_db.py
20
+ scoped helpers and carries a `:org` bind by construction (`_scoped()`
21
+ below asserts it). vault_docs / l5_drafts are not (yet) listed in
22
+ tenant_db.TENANT_TABLES β€” that registry lives in a file owned by the
23
+ tenancy workstream β€” so the DAL's parse guard does not fire for them;
24
+ the local assert plus Postgres FORCE RLS (migration 007) close the gap.
25
+ * Document content, summaries, and draft ITEMS are encrypted at rest via
26
+ atp/crypto.py encrypt_for_org (per-org HKDF subkey, 'enc1:' envelope).
27
+ The draft BLUEPRINT (section names/weights only) stays plaintext so
28
+ listings render without a decrypt.
29
+ * Approved banks are materialized IN MEMORY only. They are NEVER written
30
+ to atp/items/ or any shared directory (leak surfaces #4 and #7: no
31
+ plaintext T2 on disk, nothing under public media prefixes).
32
+
33
+ Separation of duties: admins manage docs and drafts; only SMEs sign.
34
+ approve_draft / reject_draft record the reviewer decision as a signed
35
+ 'sme_signoff' evidence row (atp/signing.py append_chain), making sign-off
36
+ tamper-evident even though l5_drafts itself is mutable by design.
37
+
38
+ Public:
39
+ ingest_doc(org_id, filename, mime, content_bytes, uploaded_by) -> doc dict
40
+ list_docs(org_id) -> [doc dict] (summaries only)
41
+ get_doc(org_id, doc_id) -> doc dict | None (full text)
42
+ generate_l5_draft(org_id, doc_ids, created_by, title='') -> draft dict
43
+ list_drafts(org_id, include_items=False) -> [draft dict]
44
+ get_draft(org_id, draft_id, include_items=False) -> draft dict | None
45
+ approve_draft(org_id, draft_id, reviewer, note='') -> sign-off dict
46
+ reject_draft(org_id, draft_id, reviewer, note='') -> sign-off dict
47
+ load_org_item_bank(org_id, cert_ref) -> {cert, items} | None
48
+ UnsupportedDocumentError / KnowledgeError / DraftStateError
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import io
54
+ import json
55
+ import re
56
+ import threading
57
+ import time
58
+ import uuid
59
+ from pathlib import Path
60
+
61
+ from atp import crypto, db, signing, tenant_db
62
+
63
+ #: Cert refs for approved drafts: f"{EXAM_CERT_PREFIX}{draft_id}". Must equal
64
+ #: atp/exams.py ORG_CERT_PREFIX (defined there β€” the loader checks the prefix
65
+ #: BEFORE importing this module; asserted equal in load_org_item_bank).
66
+ EXAM_CERT_PREFIX = "org-l5-draft:"
67
+
68
+ #: Seed cert whose passCriteria/rubric an org draft inherits β€” the L5
69
+ #: company-knowledge archetype in atp/seed.py.
70
+ L5_TEMPLATE_CERT_ID = "atp-l5-company-ops"
71
+
72
+ SUMMARY_WORDS = 40 # heuristic summary length (first N words)
73
+ MIN_ITEMS = 8 # a draft bank must have at least this many items
74
+ MIN_SECTIONS = 2 # ... spread across at least this many sections
75
+ MAX_UNITS_PER_DOC = 8 # cap: at most this many (heading, passage) units/doc
76
+ MIN_UNIT_CHARS = 40 # passages shorter than this are too thin to examine
77
+ MAX_EXPECTED_CHARS = 1200 # cap pasted passage length in judge references
78
+
79
+ _MIGRATED = False
80
+ _MIGRATE_LOCK = threading.Lock()
81
+
82
+ # (org_id, draft_id) -> materialized bank dict. In-memory ONLY (T2: approved
83
+ # banks never land as plaintext on shared disk); repopulated lazily from the
84
+ # encrypted l5_drafts row after a restart. Entries are only ever created for
85
+ # status='approved' drafts, and approve/reject are terminal transitions, so
86
+ # no invalidation is needed.
87
+ _BANK_CACHE: dict[tuple[str, str], dict] = {}
88
+ _BANK_LOCK = threading.Lock()
89
+
90
+
91
+ class KnowledgeError(ValueError):
92
+ """Invalid ingestion/drafting input (unknown doc ids, corpus too thin)."""
93
+
94
+
95
+ class UnsupportedDocumentError(KnowledgeError):
96
+ """Unsupported document type β€” the API maps this to HTTP 415."""
97
+
98
+
99
+ class DraftStateError(RuntimeError):
100
+ """A draft transition was attempted from the wrong status (HTTP 409)."""
101
+
102
+
103
+ def _now() -> str:
104
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
105
+
106
+
107
+ def _ensure_db() -> None:
108
+ """Idempotently apply migrations so a fresh SQLite file just works
109
+ (same idiom as atp/exams.py)."""
110
+ global _MIGRATED
111
+ if _MIGRATED:
112
+ return
113
+ with _MIGRATE_LOCK:
114
+ if not _MIGRATED:
115
+ db.run_migrations()
116
+ _MIGRATED = True
117
+
118
+
119
+ def _scoped(sql: str) -> str:
120
+ """Belt-and-braces layer-2 check for THIS module's tenant tables.
121
+
122
+ vault_docs / l5_drafts are not in tenant_db.TENANT_TABLES (that frozenset
123
+ lives in atp/tenant_db.py, owned by the tenancy workstream β€” follow-up:
124
+ add both there), so scoped_query/scoped_execute would not force the
125
+ ':org' bind for them. Assert it locally instead; on Postgres the
126
+ migration-007 FORCE RLS policies are the deny-by-default backstop.
127
+ """
128
+ if ":org" not in sql:
129
+ raise tenant_db.TenantScopeError(
130
+ f"knowledge-module statement lacks an ':org' bind: {sql!r}")
131
+ return sql
132
+
133
+
134
+ # ── Text extraction (md/txt native; pdf via pypdf when importable) ──────────
135
+
136
+ _EXT_KIND = {".md": "md", ".markdown": "md", ".txt": "txt", ".pdf": "pdf"}
137
+ _MIME_KIND = {
138
+ "text/markdown": "md",
139
+ "text/x-markdown": "md",
140
+ "text/plain": "txt",
141
+ "application/pdf": "pdf",
142
+ }
143
+
144
+
145
+ def doc_kind(filename: str | None, mime: str | None) -> str | None:
146
+ """'md' | 'txt' | 'pdf' from the filename extension (authoritative),
147
+ falling back to the declared MIME type; None when neither is allowed."""
148
+ ext = Path(filename or "").suffix.lower()
149
+ if ext in _EXT_KIND:
150
+ return _EXT_KIND[ext]
151
+ if ext: # an extension we do NOT allow β€” don't let MIME override it
152
+ return None
153
+ return _MIME_KIND.get((mime or "").split(";")[0].strip().lower())
154
+
155
+
156
+ def _extract_text(filename: str | None, mime: str | None,
157
+ content_bytes: bytes) -> str:
158
+ kind = doc_kind(filename, mime)
159
+ if kind is None:
160
+ raise UnsupportedDocumentError(
161
+ f"unsupported document type (filename={filename!r}, "
162
+ f"mime={mime!r}) β€” allowed: .md, .txt, .pdf")
163
+ if kind == "pdf":
164
+ try:
165
+ from pypdf import PdfReader # optional dep (requirements.txt)
166
+ except ImportError:
167
+ raise UnsupportedDocumentError(
168
+ "PDF ingestion requires the 'pypdf' package, which is not "
169
+ "installed on this deployment β€” upload .md or .txt instead"
170
+ ) from None
171
+ try:
172
+ reader = PdfReader(io.BytesIO(content_bytes))
173
+ text = "\n\n".join((page.extract_text() or "")
174
+ for page in reader.pages)
175
+ except Exception as e: # noqa: BLE001 β€” pypdf raises many types
176
+ raise KnowledgeError(f"could not parse PDF: {e}") from None
177
+ else:
178
+ text = content_bytes.decode("utf-8", errors="replace")
179
+ text = text.strip()
180
+ if not text:
181
+ raise KnowledgeError("document contains no extractable text")
182
+ return text
183
+
184
+
185
+ _HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(?P<h>.+?)\s*#*\s*$")
186
+
187
+
188
+ def _title_from(text: str, filename: str | None) -> str:
189
+ """First markdown heading, else the cleaned filename stem."""
190
+ for line in text.splitlines()[:20]:
191
+ m = _HEADING_RE.match(line)
192
+ if m:
193
+ return m.group("h").strip()
194
+ stem = Path(filename or "document").stem
195
+ return re.sub(r"[_\-]+", " ", stem).strip() or "document"
196
+
197
+
198
+ def _summarize(text: str) -> str:
199
+ """Deterministic heuristic summary: the first SUMMARY_WORDS words of the
200
+ body (heading markers stripped)."""
201
+ words = re.sub(r"^\s{0,3}#{1,6}\s+", "", text, flags=re.MULTILINE).split()
202
+ out = " ".join(words[:SUMMARY_WORDS])
203
+ return out + (" …" if len(words) > SUMMARY_WORDS else "")
204
+
205
+
206
+ def _split_units(text: str) -> list[tuple[str, str]]:
207
+ """Split a document into (heading, passage) units, deterministically.
208
+
209
+ Blocks are separated by blank lines; a block that is a single markdown
210
+ heading line sets the current heading for the passages that follow.
211
+ Headingless documents fall back to positional labels ('passage N').
212
+ Passages shorter than MIN_UNIT_CHARS are skipped as unexaminable.
213
+ """
214
+ units: list[tuple[str, str]] = []
215
+ heading: str | None = None
216
+ n_anon = 0
217
+ for block in re.split(r"\n\s*\n", text):
218
+ block = block.strip()
219
+ if not block:
220
+ continue
221
+ lines = block.splitlines()
222
+ m = _HEADING_RE.match(lines[0])
223
+ if m:
224
+ heading = m.group("h").strip()[:80]
225
+ block = "\n".join(lines[1:]).strip()
226
+ if not block:
227
+ continue
228
+ if len(block) < MIN_UNIT_CHARS:
229
+ continue
230
+ if heading is None:
231
+ n_anon += 1
232
+ label = f"passage {n_anon}"
233
+ else:
234
+ label = heading
235
+ units.append((label, block))
236
+ return units
237
+
238
+
239
+ # ── Documents ────────────────────────────────────────────────────────────────
240
+
241
+ _DOC_LIST_COLS = "id, title, filename, mime, uploaded_by, created_at, status"
242
+
243
+
244
+ def _doc_public(row: dict, org_id: str, *, content: bool = False) -> dict:
245
+ out = {
246
+ "id": row["id"],
247
+ "title": row["title"],
248
+ "filename": row.get("filename"),
249
+ "mime": row.get("mime"),
250
+ "status": row["status"],
251
+ "uploadedBy": row.get("uploaded_by"),
252
+ "createdAt": row.get("created_at"),
253
+ "summary": crypto.decrypt_text_for_org(org_id, row.get("summary_enc")),
254
+ }
255
+ if content:
256
+ out["content"] = crypto.decrypt_text_for_org(org_id, row.get("content_enc"))
257
+ return out
258
+
259
+
260
+ def ingest_doc(org_id: str, filename: str | None, mime: str | None,
261
+ content_bytes: bytes, uploaded_by: str) -> dict:
262
+ """Extract text, encrypt it under the org's subkey, store a vault_docs
263
+ row, and return the public doc dict (summary decrypted for the caller).
264
+
265
+ Raises UnsupportedDocumentError (bad type / pypdf missing β†’ HTTP 415),
266
+ KnowledgeError (unparseable/empty β†’ 400), RuntimeError from atp/crypto.py
267
+ when DATA_ENCRYPTION_KEY is unset (T2 is never written as plaintext).
268
+ """
269
+ _ensure_db()
270
+ text = _extract_text(filename, mime, content_bytes)
271
+ doc_id = "doc_" + uuid.uuid4().hex[:12]
272
+ row = {
273
+ "id": doc_id,
274
+ "title": _title_from(text, filename),
275
+ "filename": filename,
276
+ "mime": mime,
277
+ "content_enc": crypto.encrypt_for_org(org_id, text),
278
+ "summary_enc": crypto.encrypt_for_org(org_id, _summarize(text)),
279
+ "uploaded_by": uploaded_by,
280
+ "created_at": _now(),
281
+ "status": "uploaded",
282
+ }
283
+ tenant_db.scoped_execute(org_id, _scoped(
284
+ "INSERT INTO vault_docs (id, org_id, title, filename, mime, "
285
+ " content_enc, summary_enc, uploaded_by, created_at, status) "
286
+ "VALUES (:id, :org, :title, :filename, :mime, :content_enc, "
287
+ " :summary_enc, :uploaded_by, :created_at, :status)"),
288
+ {"org": org_id, **row})
289
+ return _doc_public({**row}, org_id)
290
+
291
+
292
+ def list_docs(org_id: str) -> list[dict]:
293
+ """Org's vault docs, latest first β€” summaries decrypted, NO content."""
294
+ _ensure_db()
295
+ rows = tenant_db.scoped_query(org_id, _scoped(
296
+ f"SELECT {_DOC_LIST_COLS}, summary_enc FROM vault_docs "
297
+ f"WHERE org_id = :org ORDER BY created_at DESC, id DESC"),
298
+ {"org": org_id})
299
+ return [_doc_public(r, org_id) for r in rows]
300
+
301
+
302
+ def _doc_row(org_id: str, doc_id: str) -> dict | None:
303
+ rows = tenant_db.scoped_query(org_id, _scoped(
304
+ "SELECT * FROM vault_docs WHERE org_id = :org AND id = :id LIMIT 1"),
305
+ {"org": org_id, "id": doc_id})
306
+ return rows[0] if rows else None
307
+
308
+
309
+ def get_doc(org_id: str, doc_id: str) -> dict | None:
310
+ """One doc with the FULL decrypted text, or None (cross-tenant ids are
311
+ indistinguishable from unknown ones β€” TENANCY.md leak surface #6)."""
312
+ _ensure_db()
313
+ row = _doc_row(org_id, doc_id)
314
+ return None if row is None else _doc_public(row, org_id, content=True)
315
+
316
+
317
+ # ── L5 draft generation (deterministic heuristic β€” no LLM dependency) ───────
318
+
319
+ def _items_for_doc(doc_idx: int, title: str, units: list[tuple[str, str]]
320
+ ) -> list[dict]:
321
+ """Recall + apply item pair per (heading, passage) unit. Pure function of
322
+ its inputs β€” same docs in the same order always produce the same items."""
323
+ items: list[dict] = []
324
+ for u_idx, (heading, passage) in enumerate(units[:MAX_UNITS_PER_DOC], 1):
325
+ passage = passage[:MAX_EXPECTED_CHARS]
326
+ base = f"l5d-{doc_idx:02d}-{u_idx:02d}"
327
+ items.append({
328
+ "id": f"{base}-r",
329
+ "section": title,
330
+ "type": "recall",
331
+ "prompt": (f'According to "{title}", what is stated about '
332
+ f'"{heading}"?'),
333
+ "expected": (f'Full credit requires accurately restating the '
334
+ f'substance of this passage from "{title}" '
335
+ f'(section: {heading}); contradicting it or '
336
+ f'inventing unstated policy is wrong:\n{passage}'),
337
+ "points": 2,
338
+ })
339
+ items.append({
340
+ "id": f"{base}-a",
341
+ "section": title,
342
+ "type": "apply",
343
+ "prompt": (f'A teammate must follow the guidance in "{title}" '
344
+ f'regarding "{heading}". Explain, step by step, how '
345
+ f'to apply what the document states, and note when '
346
+ f'to escalate instead of guessing.'),
347
+ "expected": (f'Full credit requires steps grounded ONLY in this '
348
+ f'passage from "{title}" (section: {heading}) β€” no '
349
+ f'invented policy; ambiguous cases must escalate:\n'
350
+ f'{passage}'),
351
+ "points": 3,
352
+ })
353
+ return items
354
+
355
+
356
+ def _draft_public(row: dict, org_id: str, *, include_items: bool) -> dict:
357
+ blueprint = json.loads(row["blueprint_json"])
358
+ out = {
359
+ "id": row["id"],
360
+ "certLabel": row["cert_label"],
361
+ "blueprint": blueprint,
362
+ "docIds": json.loads(row["doc_ids_json"]),
363
+ "status": row["status"],
364
+ "createdBy": row.get("created_by"),
365
+ "createdAt": row.get("created_at"),
366
+ "reviewedBy": row.get("reviewed_by"),
367
+ "reviewedAt": row.get("reviewed_at"),
368
+ "reviewNote": row.get("review_note"),
369
+ "itemCount": sum(int(s.get("items", 0))
370
+ for s in blueprint.get("sections", [])),
371
+ "examCertRef": EXAM_CERT_PREFIX + row["id"],
372
+ }
373
+ if include_items:
374
+ out["items"] = json.loads(
375
+ crypto.decrypt_text_for_org(org_id, row["items_json_enc"]))
376
+ return out
377
+
378
+
379
+ def generate_l5_draft(org_id: str, doc_ids: list[str], created_by: str,
380
+ title: str = "") -> dict:
381
+ """Deterministic heuristic L5 exam draft from the org's vault docs.
382
+
383
+ Blueprint sections = the docs' titles (one section per doc, weights
384
+ proportional to item count, normalized to sum 1). Items = a recall +
385
+ apply pair per (heading, passage) unit of each doc. Requires >= 2 docs
386
+ with distinct titles and >= 8 items total, else KnowledgeError.
387
+
388
+ Blueprint is stored plaintext; ITEMS (T2 β€” verbatim company passages)
389
+ are encrypted under the org subkey. Returns the draft dict with items
390
+ DECRYPTED for immediate SME review (the route role-gates that).
391
+ """
392
+ _ensure_db()
393
+ if not doc_ids:
394
+ raise KnowledgeError("docIds must be a non-empty list")
395
+ if len(set(doc_ids)) != len(doc_ids):
396
+ raise KnowledgeError("docIds contains duplicates")
397
+
398
+ docs: list[dict] = []
399
+ missing: list[str] = []
400
+ for doc_id in doc_ids:
401
+ row = _doc_row(org_id, doc_id)
402
+ if row is None:
403
+ missing.append(doc_id)
404
+ else:
405
+ docs.append(row)
406
+ if missing:
407
+ # Cross-tenant ids fail EXACTLY like unknown ones (TENANCY.md #6);
408
+ # only ids the caller itself supplied are echoed back.
409
+ raise KnowledgeError(f"unknown doc id(s): {sorted(missing)}")
410
+
411
+ items: list[dict] = []
412
+ sections: list[dict] = []
413
+ seen_titles: set[str] = set()
414
+ for d_idx, row in enumerate(docs, 1):
415
+ title_d = row["title"]
416
+ if title_d in seen_titles:
417
+ raise KnowledgeError(
418
+ f"two selected docs share the title {title_d!r} β€” blueprint "
419
+ f"sections are doc titles and must be distinct")
420
+ seen_titles.add(title_d)
421
+ text = crypto.decrypt_text_for_org(org_id, row["content_enc"])
422
+ units = _split_units(text)
423
+ doc_items = _items_for_doc(d_idx, title_d, units)
424
+ if not doc_items:
425
+ raise KnowledgeError(
426
+ f"doc {row['id']} ({title_d!r}) yields no examinable "
427
+ f"passages (needs paragraphs of >= {MIN_UNIT_CHARS} chars)")
428
+ items.extend(doc_items)
429
+ sections.append({"name": title_d, "items": len(doc_items),
430
+ "itemTypes": ["recall", "apply"]})
431
+
432
+ if len(sections) < MIN_SECTIONS:
433
+ raise KnowledgeError(
434
+ f"an L5 draft needs >= {MIN_SECTIONS} blueprint sections "
435
+ f"(= distinct docs); got {len(sections)}")
436
+ if len(items) < MIN_ITEMS:
437
+ raise KnowledgeError(
438
+ f"an L5 draft needs >= {MIN_ITEMS} items; these docs only "
439
+ f"yield {len(items)} β€” upload richer documents")
440
+
441
+ # Weights proportional to item count, normalized to sum exactly 1
442
+ # (the last section absorbs the rounding remainder).
443
+ total = len(items)
444
+ for s in sections[:-1]:
445
+ s["weight"] = round(s["items"] / total, 6)
446
+ sections[-1]["weight"] = round(1.0 - sum(s["weight"]
447
+ for s in sections[:-1]), 6)
448
+
449
+ draft_id = "l5d_" + uuid.uuid4().hex[:12]
450
+ blueprint = {"sections": sections, "totalItems": total}
451
+ row = {
452
+ "id": draft_id,
453
+ "cert_label": (title or "").strip() or "ATP-L5 Company Knowledge (draft)",
454
+ "blueprint_json": json.dumps(blueprint, sort_keys=True),
455
+ "items_json_enc": crypto.encrypt_for_org(org_id, json.dumps(items)),
456
+ "doc_ids_json": json.dumps(list(doc_ids)),
457
+ "status": "draft",
458
+ "created_by": created_by,
459
+ "created_at": _now(),
460
+ }
461
+ tenant_db.scoped_execute(org_id, _scoped(
462
+ "INSERT INTO l5_drafts (id, org_id, cert_label, blueprint_json, "
463
+ " items_json_enc, doc_ids_json, status, created_by, created_at) "
464
+ "VALUES (:id, :org, :cert_label, :blueprint_json, :items_json_enc, "
465
+ " :doc_ids_json, :status, :created_by, :created_at)"),
466
+ {"org": org_id, **row})
467
+
468
+ # uploaded -> drafted (docs already 'drafted'/'signed' keep their status).
469
+ for doc_id in doc_ids:
470
+ tenant_db.scoped_execute(org_id, _scoped(
471
+ "UPDATE vault_docs SET status = 'drafted' "
472
+ "WHERE org_id = :org AND id = :id AND status = 'uploaded'"),
473
+ {"org": org_id, "id": doc_id})
474
+
475
+ return _draft_public(row, org_id, include_items=True)
476
+
477
+
478
+ def list_drafts(org_id: str, include_items: bool = False) -> list[dict]:
479
+ """Org's L5 drafts, latest first. Items (T2) only when include_items β€”
480
+ the route restricts that to admin|sme."""
481
+ _ensure_db()
482
+ rows = tenant_db.scoped_query(org_id, _scoped(
483
+ "SELECT * FROM l5_drafts WHERE org_id = :org "
484
+ "ORDER BY created_at DESC, id DESC"),
485
+ {"org": org_id})
486
+ return [_draft_public(r, org_id, include_items=include_items) for r in rows]
487
+
488
+
489
+ def _draft_row(org_id: str, draft_id: str) -> dict | None:
490
+ rows = tenant_db.scoped_query(org_id, _scoped(
491
+ "SELECT * FROM l5_drafts WHERE org_id = :org AND id = :id LIMIT 1"),
492
+ {"org": org_id, "id": draft_id})
493
+ return rows[0] if rows else None
494
+
495
+
496
+ def get_draft(org_id: str, draft_id: str,
497
+ include_items: bool = False) -> dict | None:
498
+ _ensure_db()
499
+ row = _draft_row(org_id, draft_id)
500
+ return None if row is None else _draft_public(
501
+ row, org_id, include_items=include_items)
502
+
503
+
504
+ # ── SME sign-off (separation of duties; evidence on the signed chain) ───────
505
+
506
+ def _review(org_id: str, draft_id: str, reviewer: str, note: str,
507
+ to_status: str, outcome: str) -> dict | None:
508
+ """Shared approve/reject transition: guarded UPDATE from 'draft', then a
509
+ signed 'sme_signoff' evidence row. Returns None for unknown/cross-org
510
+ ids; DraftStateError when the draft is not in status 'draft'."""
511
+ _ensure_db()
512
+ row = _draft_row(org_id, draft_id)
513
+ if row is None:
514
+ return None
515
+ if row["status"] != "draft":
516
+ raise DraftStateError(
517
+ f"draft {draft_id} is '{row['status']}' β€” only status 'draft' "
518
+ f"can be {outcome}")
519
+ ts = _now()
520
+ res = tenant_db.scoped_execute(org_id, _scoped(
521
+ "UPDATE l5_drafts SET status = :to, reviewed_by = :rev, "
522
+ " reviewed_at = :ts, review_note = :note "
523
+ "WHERE org_id = :org AND id = :id AND status = 'draft'"),
524
+ {"org": org_id, "id": draft_id, "to": to_status,
525
+ "rev": reviewer, "ts": ts, "note": note})
526
+ if res.rowcount == 0: # lost a race β€” same answer as the pre-check
527
+ raise DraftStateError(
528
+ f"draft {draft_id} was reviewed concurrently β€” reload it")
529
+
530
+ cert_ref = EXAM_CERT_PREFIX + draft_id
531
+ doc_ids = json.loads(row["doc_ids_json"])
532
+ # The sign-off itself is the tamper-evident record (l5_drafts is mutable
533
+ # by design β€” migration 007 header): one HMAC-signed row on the org's
534
+ # evidence chain. NOTE: payload carries reviewer/note/doc ids β€” metadata,
535
+ # never document content or items (atp_evidence is not encrypted).
536
+ payload = {"draftId": draft_id, "reviewer": reviewer, "note": note,
537
+ "docIds": doc_ids, "certLabel": row["cert_label"],
538
+ "outcome": outcome}
539
+ (evidence_id, _sig), = signing.append_chain(org_id, [{
540
+ "ts": ts, "agent_id": reviewer, "cert_id": cert_ref,
541
+ "kind": "sme_signoff", "payload": payload,
542
+ }])
543
+
544
+ if to_status == "approved":
545
+ for doc_id in doc_ids: # drafted -> signed
546
+ tenant_db.scoped_execute(org_id, _scoped(
547
+ "UPDATE vault_docs SET status = 'signed' "
548
+ "WHERE org_id = :org AND id = :id"),
549
+ {"org": org_id, "id": doc_id})
550
+ # Materialize the bank in memory now (also happens lazily on demand).
551
+ load_org_item_bank(org_id, cert_ref)
552
+
553
+ return {
554
+ "draftId": draft_id,
555
+ "status": to_status,
556
+ "outcome": outcome,
557
+ "reviewedBy": reviewer,
558
+ "reviewedAt": ts,
559
+ "note": note,
560
+ "evidenceId": evidence_id,
561
+ "examCertRef": cert_ref,
562
+ "examRunnable": to_status == "approved",
563
+ }
564
+
565
+
566
+ def approve_draft(org_id: str, draft_id: str, reviewer: str,
567
+ note: str = "") -> dict | None:
568
+ """SME approval: draft -> approved, 'sme_signoff' evidence appended,
569
+ covered docs flip to 'signed', and the exam bank becomes loadable via
570
+ load_org_item_bank (cert ref in the returned 'examCertRef')."""
571
+ return _review(org_id, draft_id, reviewer, note, "approved", "approved")
572
+
573
+
574
+ def reject_draft(org_id: str, draft_id: str, reviewer: str,
575
+ note: str = "") -> dict | None:
576
+ """SME rejection: draft -> rejected, with the SAME evidence trail
577
+ (kind 'sme_signoff', payload.outcome = 'rejected')."""
578
+ return _review(org_id, draft_id, reviewer, note, "rejected", "rejected")
579
+
580
+
581
+ # ── Exam extension point (consumed by atp/exams.py's Phase 6 loader path) ───
582
+
583
+ def _template_cert() -> dict:
584
+ """passCriteria/rubric archetype for org L5 drafts, deep-copied from the
585
+ seed L5 cert so the T0 seed is never mutated."""
586
+ from atp import store
587
+ for cert in store.get_data().get("CERTS", []):
588
+ if cert.get("id") == L5_TEMPLATE_CERT_ID:
589
+ return json.loads(json.dumps(cert)) # cheap deep copy
590
+ # Defensive fallback β€” the seed always ships this cert.
591
+ return {"passCriteria": {"overall": 0.9, "perSection": 0.85, "runs": 2,
592
+ "temperature": 0, "seed": 5051,
593
+ "judgeSeparation": True},
594
+ "rubric": []}
595
+
596
+
597
+ def load_org_item_bank(org_id: str, cert_ref: str) -> dict | None:
598
+ """Materialize an org's APPROVED draft bank in memory for atp/exams.py.
599
+
600
+ cert_ref: f"{EXAM_CERT_PREFIX}<draft_id>". Returns
601
+ {"cert": <synthetic cert dict>, "items": [item, ...]}
602
+ or None when the ref does not name an approved draft of THIS org (a
603
+ cross-tenant ref is indistinguishable from an unknown one β€” #6).
604
+
605
+ The synthetic cert carries the draft's own blueprint (sections = doc
606
+ titles) plus the passCriteria/rubric of the seed L5 template cert, so
607
+ run_exam()'s aggregation and verdict logic work unchanged. Decrypted
608
+ items live only in this process's memory (_BANK_CACHE) β€” never on disk.
609
+ """
610
+ if not cert_ref.startswith(EXAM_CERT_PREFIX):
611
+ return None
612
+ draft_id = cert_ref[len(EXAM_CERT_PREFIX):]
613
+ key = (org_id, draft_id)
614
+ with _BANK_LOCK:
615
+ cached = _BANK_CACHE.get(key)
616
+ if cached is not None:
617
+ return cached
618
+
619
+ _ensure_db()
620
+ row = _draft_row(org_id, draft_id)
621
+ if row is None or row["status"] != "approved":
622
+ return None
623
+ items = json.loads(crypto.decrypt_text_for_org(org_id, row["items_json_enc"]))
624
+ template = _template_cert()
625
+ cert = {
626
+ "id": cert_ref,
627
+ "label": row["cert_label"],
628
+ "sentence": (f"Org-scoped L5 company-knowledge draft "
629
+ f"({row['cert_label']}), SME-approved."),
630
+ "layer": 5,
631
+ "domain": "Company Knowledge",
632
+ "blueprint": json.loads(row["blueprint_json"]),
633
+ "rubric": template.get("rubric", []),
634
+ "passCriteria": template.get("passCriteria", {}),
635
+ "smeSignoff": [{"name": row.get("reviewed_by"),
636
+ "ts": row.get("reviewed_at")}],
637
+ }
638
+ bank = {"cert": cert, "items": items, "draftId": draft_id}
639
+ with _BANK_LOCK:
640
+ _BANK_CACHE[key] = bank
641
+ return bank
atp/licensing.py ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent licensing β€” issue / verify / revoke / meter (Phase 4, docs/HARDENING.md).
3
+
4
+ Licenses are T1 org-scoped data (docs/TENANCY.md): every read/write goes
5
+ through atp/tenant_db.py (org from the verified request principal, NEVER from
6
+ a payload), and on Postgres migration 006 adds FORCE row-level security with
7
+ the 005 policy pattern. Stripe is OPTIONAL by design: without STRIPE_API_KEY
8
+ the platform still issues 'manual' licenses (design partners pay by invoice)
9
+ β€” stripe_customer/stripe_subscription stay NULL and gating/metering/
10
+ revocation work identically. Stripe only adds self-serve checkout on top.
11
+
12
+ License-key format (shown ONCE at issue time; only key_id is persisted):
13
+
14
+ atpk_<key_id>.<sig>
15
+ key_id = 24 hex chars (secrets.token_hex(12))
16
+ sig = HMAC-SHA256(LICENSE_SIGNING_KEY, key_id) hex, first 32 chars
17
+
18
+ Verification model β€” the key alone NEVER suffices:
19
+
20
+ check_license() first verifies the HMAC in constant time (cheap, no DB
21
+ hit for forged keys), then loads the row by key_id and enforces
22
+ status == 'active' AND (expires_at IS NULL OR in the future). Revocation
23
+ and expiry are DB STATE β€” a revoked/expired row fails on the very next
24
+ call regardless of a validly signed key. Rows whose expires_at has passed
25
+ are auto-flipped to status='expired' on check (`licenses` is mutable by
26
+ design; `usage_events` + the atp_evidence chain are the append-only
27
+ billing/audit record).
28
+
29
+ Key resolution (mirrors atp/signing.py, fail-loud):
30
+
31
+ LICENSE_SIGNING_KEY required whenever keys are issued/verified with
32
+ auth enabled β€” LicenseSigningKeyError if unset.
33
+ BU_AUTH_DISABLED=1 (dev) fallback: derive a stable key from
34
+ BU_AUTH_SECRET when set, else a process-
35
+ ephemeral random key with a loud warning
36
+ (keys then don't verify across restarts).
37
+
38
+ Public:
39
+ issue_license(org_id, agent_id, kind, expires_at=None,
40
+ stripe_customer=None, stripe_subscription=None)
41
+ -> {license, licenseKey}
42
+ check_license(license_key) -> {ok, license?, reason?}
43
+ revoke_license(org_id, license_id, reason) -> license dict (idempotent)
44
+ record_usage(org_id, license_id, agent_id, endpoint,
45
+ tokens_in, tokens_out, status) -> usage-event dict
46
+ usage_summary(org_id, license_id=None)
47
+ -> {calls, tokensIn, tokensOut, byAgent}
48
+ list_licenses(org_id) -> [license dict] latest-first
49
+
50
+ Returned license dicts are camelCase and NEVER contain key material: the
51
+ secret half of the key is not stored anywhere; `keyId` (the public identifier
52
+ half, useless without LICENSE_SIGNING_KEY) is included so an admin can match
53
+ a key in hand to its row. Like atp/store.py, returned dicts do not grow an
54
+ org key.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import hashlib
60
+ import hmac
61
+ import os
62
+ import secrets
63
+ import sys
64
+ import time
65
+ import uuid
66
+ from datetime import datetime, timezone
67
+
68
+ from sqlalchemy import text
69
+
70
+ from atp import db, store, tenant_db
71
+
72
+ #: License-key prefix (public, greppable β€” like Stripe's sk_/pk_).
73
+ KEY_PREFIX = "atpk_"
74
+
75
+ #: Hex chars of the HMAC kept in the key (128 bits of the 256-bit MAC).
76
+ _SIG_LEN = 32
77
+
78
+ #: Domain-separation tag for the BU_AUTH_SECRET-derived dev key β€” distinct
79
+ #: from atp/signing.py's tag so evidence and license keys never share a key.
80
+ _DERIVE_TAG = b"bu-license-signing-v1:"
81
+
82
+ VALID_KINDS = frozenset({"evaluation", "production"})
83
+
84
+ _EPHEMERAL_KEY: bytes | None = None
85
+ _WARNED = False
86
+ _MIGRATIONS_DONE = False
87
+
88
+
89
+ class LicensingError(RuntimeError):
90
+ """Base class for licensing failures."""
91
+
92
+
93
+ class LicenseSigningKeyError(LicensingError):
94
+ """Key issue/verify was requested but no usable signing key is set."""
95
+
96
+
97
+ class UnknownAgentError(LicensingError):
98
+ """agent_id does not exist in the marketplace seed."""
99
+
100
+
101
+ class AgentNotLicensableError(LicensingError):
102
+ """Agent exists but its seed says licensing.available is false."""
103
+
104
+
105
+ class LicenseNotFoundError(LicensingError):
106
+ """No license with that id is visible to the caller's org."""
107
+
108
+
109
+ # ── Signing key (mirrors atp/signing.py) ────────────────────────────────────
110
+
111
+ def _signing_key() -> bytes:
112
+ """Resolve the HMAC key (see module docstring for the precedence)."""
113
+ global _EPHEMERAL_KEY, _WARNED
114
+ key = os.environ.get("LICENSE_SIGNING_KEY", "").strip()
115
+ if key:
116
+ return key.encode("utf-8")
117
+
118
+ if os.environ.get("BU_AUTH_DISABLED", "") == "1":
119
+ # Dev mode: never block the loop, but never sign with a hardcoded
120
+ # default either β€” derive from the auth secret when one exists.
121
+ auth_secret = os.environ.get("BU_AUTH_SECRET", "").strip()
122
+ if auth_secret:
123
+ return hashlib.sha256(_DERIVE_TAG + auth_secret.encode("utf-8")).digest()
124
+ if _EPHEMERAL_KEY is None:
125
+ _EPHEMERAL_KEY = secrets.token_bytes(32)
126
+ if not _WARNED:
127
+ _WARNED = True
128
+ print(
129
+ "WARNING: LICENSE_SIGNING_KEY unset (BU_AUTH_DISABLED=1 dev "
130
+ "mode) β€” signing license keys with a process-EPHEMERAL key. "
131
+ "Issued keys will NOT verify after a restart. Set "
132
+ "LICENSE_SIGNING_KEY (see .env.example).",
133
+ file=sys.stderr,
134
+ )
135
+ return _EPHEMERAL_KEY
136
+
137
+ raise LicenseSigningKeyError(
138
+ "LICENSE_SIGNING_KEY is not set. License-key signing is mandatory "
139
+ "when auth is enabled (docs/HARDENING.md Phase 4) β€” generate one with "
140
+ "python3 -c \"import secrets; print(secrets.token_hex(32))\" and "
141
+ "export it, or export BU_AUTH_DISABLED=1 for local dev only."
142
+ )
143
+
144
+
145
+ def _key_sig(key_id: str) -> str:
146
+ """Secret half of a license key: truncated hex HMAC-SHA256 over key_id."""
147
+ return hmac.new(
148
+ _signing_key(), key_id.encode("utf-8"), hashlib.sha256
149
+ ).hexdigest()[:_SIG_LEN]
150
+
151
+
152
+ def _parse_key(license_key) -> tuple[str, str] | None:
153
+ """Split 'atpk_<key_id>.<sig>' β†’ (key_id, sig); None when malformed."""
154
+ if not isinstance(license_key, str) or not license_key.startswith(KEY_PREFIX):
155
+ return None
156
+ key_id, dot, sig = license_key[len(KEY_PREFIX):].partition(".")
157
+ if not dot or not key_id or not sig:
158
+ return None
159
+ return key_id, sig
160
+
161
+
162
+ # ── Row plumbing ─────────────────────────────────────────────────────────────
163
+
164
+ # camelCase API key ↔ snake_case column; declaration order = SELECT order.
165
+ _LICENSE_COLS = {
166
+ "id": "id",
167
+ "agentId": "agent_id",
168
+ "kind": "kind",
169
+ "status": "status",
170
+ "keyId": "key_id", # public identifier half only β€” never secret
171
+ "createdAt": "created_at",
172
+ "expiresAt": "expires_at",
173
+ "revokedAt": "revoked_at",
174
+ "revokedReason": "revoked_reason",
175
+ "stripeCustomer": "stripe_customer",
176
+ "stripeSubscription": "stripe_subscription",
177
+ "seats": "seats",
178
+ }
179
+ _LICENSE_SELECT = ", ".join(_LICENSE_COLS.values())
180
+
181
+
182
+ def _to_camel(row: dict) -> dict:
183
+ """Map a licenses row to the public camelCase shape (org_id dropped β€”
184
+ same convention as atp/store.py: returned dicts don't grow an org key)."""
185
+ return {camel: row.get(col) for camel, col in _LICENSE_COLS.items()}
186
+
187
+
188
+ def _now_iso() -> str:
189
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
190
+
191
+
192
+ def _parse_ts(value) -> datetime | None:
193
+ """ISO-8601 β†’ aware UTC datetime; None when unparseable."""
194
+ if not value or not isinstance(value, str):
195
+ return None
196
+ try:
197
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
198
+ tzinfo=timezone.utc)
199
+ except ValueError:
200
+ pass
201
+ try:
202
+ dt = datetime.fromisoformat(value)
203
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
204
+ except ValueError:
205
+ return None
206
+
207
+
208
+ def _ensure_migrations() -> None:
209
+ """Lazily apply pending migrations once per process (idempotent β€” the
210
+ startup run stays authoritative; this covers direct library use/tests)."""
211
+ global _MIGRATIONS_DONE
212
+ if not _MIGRATIONS_DONE:
213
+ db.run_migrations()
214
+ _MIGRATIONS_DONE = True
215
+
216
+
217
+ def _load_by_key_id(key_id: str) -> dict | None:
218
+ """Load one licenses row by key_id β€” the key-based lookup path.
219
+
220
+ check_license() authenticates by license KEY, not by org (the caller of
221
+ the gated proxy may be a machine holding only the key); the org comes
222
+ FROM the row. On Postgres the `key_lookup` SELECT policy (migration 006)
223
+ makes exactly this one row visible after pinning the verified key_id:
224
+ SELECT set_config('app.license_key_id', :kid, true) -- == SET LOCAL
225
+ in the same transaction. Writes still require the org GUC, so everything
226
+ that follows (expiry flip, metering) goes back through atp/tenant_db.py
227
+ scoped to the row's org.
228
+ """
229
+ engine = db.get_engine()
230
+ with engine.begin() as conn:
231
+ if engine.dialect.name == "postgresql":
232
+ conn.execute(
233
+ text("SELECT set_config('app.license_key_id', :kid, true)"),
234
+ {"kid": key_id},
235
+ )
236
+ row = conn.execute(
237
+ text(f"SELECT org_id, {_LICENSE_SELECT} FROM licenses "
238
+ "WHERE key_id = :kid"),
239
+ {"kid": key_id},
240
+ ).mappings().first()
241
+ return dict(row) if row else None
242
+
243
+
244
+ # ── Public API ───────────────────────────────────────────────────────────────
245
+
246
+ def issue_license(org_id: str, agent_id: str, kind: str,
247
+ expires_at: str | None = None,
248
+ stripe_customer: str | None = None,
249
+ stripe_subscription: str | None = None) -> dict:
250
+ """Issue a license for a marketplace agent to `org_id`.
251
+
252
+ Returns {"license": <camelCase dict>, "licenseKey": "atpk_..."} β€” the
253
+ licenseKey is shown exactly ONCE here; only its key_id half is stored.
254
+ Manual (invoice-billed) licenses simply pass no stripe_* values.
255
+
256
+ Raises UnknownAgentError / AgentNotLicensableError when the agent is not
257
+ in the seed or its seed licensing says available=false; ValueError on a
258
+ bad kind or unparseable expires_at.
259
+ """
260
+ agent = next(
261
+ (a for a in store.get_data()["AGENTS"] if a.get("id") == agent_id),
262
+ None)
263
+ if agent is None:
264
+ raise UnknownAgentError(f"unknown agent {agent_id!r}")
265
+ if not (agent.get("licensing") or {}).get("available"):
266
+ raise AgentNotLicensableError(
267
+ f"agent {agent_id!r} is not available for licensing")
268
+ if kind not in VALID_KINDS:
269
+ raise ValueError(
270
+ f"kind must be one of {sorted(VALID_KINDS)}, got {kind!r}")
271
+ if expires_at is not None and _parse_ts(expires_at) is None:
272
+ raise ValueError(
273
+ f"expires_at must be ISO-8601 UTC ('%Y-%m-%dT%H:%M:%SZ'), "
274
+ f"got {expires_at!r}")
275
+
276
+ _ensure_migrations()
277
+ key_id = secrets.token_hex(12) # 96 bits; UNIQUE constraint is backstop
278
+ license_key = f"{KEY_PREFIX}{key_id}.{_key_sig(key_id)}"
279
+ row = {
280
+ "id": f"lic-{uuid.uuid4().hex[:12]}",
281
+ "agent_id": agent_id,
282
+ "kind": kind,
283
+ "status": "active",
284
+ "key_id": key_id,
285
+ "created_at": _now_iso(),
286
+ "expires_at": expires_at,
287
+ "revoked_at": None,
288
+ "revoked_reason": None,
289
+ "stripe_customer": stripe_customer,
290
+ "stripe_subscription": stripe_subscription,
291
+ "seats": 1,
292
+ }
293
+ tenant_db.scoped_execute(
294
+ org_id,
295
+ "INSERT INTO licenses "
296
+ " (id, org_id, agent_id, kind, status, key_id, created_at, "
297
+ " expires_at, revoked_at, revoked_reason, stripe_customer, "
298
+ " stripe_subscription, seats) "
299
+ "VALUES (:id, :org, :agent_id, :kind, :status, :key_id, :created_at, "
300
+ " :expires_at, :revoked_at, :revoked_reason, :stripe_customer, "
301
+ " :stripe_subscription, :seats)",
302
+ {"org": org_id, **row},
303
+ )
304
+ return {"license": _to_camel(row), "licenseKey": license_key}
305
+
306
+
307
+ def check_license(license_key) -> dict:
308
+ """Verify a presented license key. -> {ok, license?, reason?}
309
+
310
+ Order of enforcement:
311
+ 1. parse β€” malformed keys fail without touching the signing key or DB;
312
+ 2. constant-time HMAC verify (hmac.compare_digest) β€” forged keys fail
313
+ without a DB hit;
314
+ 3. DB state β€” the row must exist, have status 'active', and not be past
315
+ expires_at. Revoked/expired rows fail REGARDLESS of a valid
316
+ signature: revocation is DB state, the key alone never suffices.
317
+ Rows expired by date but still marked 'active' are flipped to 'expired'
318
+ here (scoped to the row's own org).
319
+
320
+ reason ∈ {'malformed', 'invalid-signature', 'unknown', 'revoked',
321
+ 'expired'}; on success the license dict is returned (no key material).
322
+ """
323
+ parsed = _parse_key(license_key)
324
+ if parsed is None:
325
+ return {"ok": False, "reason": "malformed"}
326
+ key_id, sig = parsed
327
+ expected = _key_sig(key_id)
328
+ if not hmac.compare_digest(expected.encode("utf-8"), sig.encode("utf-8")):
329
+ return {"ok": False, "reason": "invalid-signature"}
330
+
331
+ _ensure_migrations()
332
+ row = _load_by_key_id(key_id)
333
+ if row is None:
334
+ return {"ok": False, "reason": "unknown"}
335
+ if row["status"] == "revoked":
336
+ return {"ok": False, "reason": "revoked"}
337
+
338
+ expires = _parse_ts(row.get("expires_at")) if row.get("expires_at") else None
339
+ past_expiry = row.get("expires_at") is not None and (
340
+ expires is None # unparseable date in DB β†’ fail safe (deny)
341
+ or expires <= datetime.now(timezone.utc))
342
+ if row["status"] == "expired" or past_expiry:
343
+ if row["status"] == "active":
344
+ # Auto-transition: expiry-by-date becomes durable DB state.
345
+ # `licenses` is mutable by design (only usage/evidence are
346
+ # append-only); guard on status for concurrent checks.
347
+ tenant_db.scoped_execute(
348
+ row["org_id"],
349
+ "UPDATE licenses SET status = 'expired' "
350
+ "WHERE org_id = :org AND id = :id AND status = 'active'",
351
+ {"org": row["org_id"], "id": row["id"]},
352
+ )
353
+ return {"ok": False, "reason": "expired"}
354
+
355
+ return {"ok": True, "license": _to_camel(row)}
356
+
357
+
358
+ def revoke_license(org_id: str, license_id: str, reason: str) -> dict:
359
+ """Revoke a license (idempotent). Returns the camelCase license dict.
360
+
361
+ Org-scoped: a license belonging to another org is simply not visible, so
362
+ cross-org revocation raises LicenseNotFoundError. Takes effect on the
363
+ very next check_license() call β€” the gate re-reads DB state every time.
364
+ """
365
+ _ensure_migrations()
366
+ rows = tenant_db.scoped_query(
367
+ org_id,
368
+ f"SELECT {_LICENSE_SELECT} FROM licenses "
369
+ "WHERE org_id = :org AND id = :id",
370
+ {"org": org_id, "id": license_id},
371
+ )
372
+ if not rows:
373
+ raise LicenseNotFoundError(
374
+ f"no license {license_id!r} in org {org_id!r}")
375
+ row = rows[0]
376
+ if row["status"] == "revoked":
377
+ return _to_camel(row) # already revoked β€” idempotent no-op
378
+ row["status"] = "revoked"
379
+ row["revoked_at"] = _now_iso()
380
+ row["revoked_reason"] = str(reason or "")
381
+ tenant_db.scoped_execute(
382
+ org_id,
383
+ "UPDATE licenses SET status = 'revoked', revoked_at = :ts, "
384
+ "revoked_reason = :reason WHERE org_id = :org AND id = :id",
385
+ {"org": org_id, "id": license_id,
386
+ "ts": row["revoked_at"], "reason": row["revoked_reason"]},
387
+ )
388
+ return _to_camel(row)
389
+
390
+
391
+ def reissue_key(org_id: str, license_id: str) -> dict:
392
+ """Mint a NEW key for an existing ACTIVE license (old key stops working
393
+ immediately β€” key_id is replaced, and check_license resolves by key_id).
394
+
395
+ The in-product delivery path for Stripe self-serve licenses, whose
396
+ webhook-issued plaintext key is discarded by design: an org admin
397
+ re-issues and hands the fresh key to the customer. Returns
398
+ {"license": ..., "licenseKey": ...}; the key appears exactly ONCE here.
399
+ """
400
+ _ensure_migrations()
401
+ rows = tenant_db.scoped_query(
402
+ org_id,
403
+ f"SELECT {_LICENSE_SELECT} FROM licenses "
404
+ "WHERE org_id = :org AND id = :id",
405
+ {"org": org_id, "id": license_id},
406
+ )
407
+ if not rows:
408
+ raise LicenseNotFoundError(
409
+ f"no license {license_id!r} in org {org_id!r}")
410
+ if rows[0]["status"] != "active":
411
+ raise ValueError(
412
+ f"only active licenses can be re-keyed "
413
+ f"(status is {rows[0]['status']!r})")
414
+ key_id = secrets.token_hex(12)
415
+ license_key = f"{KEY_PREFIX}{key_id}.{_key_sig(key_id)}"
416
+ tenant_db.scoped_execute(
417
+ org_id,
418
+ "UPDATE licenses SET key_id = :key_id "
419
+ "WHERE org_id = :org AND id = :id",
420
+ {"org": org_id, "id": license_id, "key_id": key_id},
421
+ )
422
+ rows[0]["key_id"] = key_id
423
+ return {"license": _to_camel(rows[0]), "licenseKey": license_key}
424
+
425
+
426
+ def record_usage(org_id: str, license_id: str | None, agent_id: str,
427
+ endpoint: str, tokens_in: int, tokens_out: int,
428
+ status: str) -> dict:
429
+ """Append one metering row to usage_events (append-only billing evidence).
430
+
431
+ Returns the camelCase event including its row id.
432
+ """
433
+ _ensure_migrations()
434
+ event = {
435
+ "ts": _now_iso(),
436
+ "license_id": license_id,
437
+ "agent_id": agent_id,
438
+ "endpoint": endpoint,
439
+ "tokens_in": int(tokens_in or 0),
440
+ "tokens_out": int(tokens_out or 0),
441
+ "status": str(status),
442
+ }
443
+ result = tenant_db.scoped_execute(
444
+ org_id,
445
+ "INSERT INTO usage_events "
446
+ " (ts, org_id, license_id, agent_id, endpoint, tokens_in, "
447
+ " tokens_out, status) "
448
+ "VALUES (:ts, :org, :license_id, :agent_id, :endpoint, :tokens_in, "
449
+ " :tokens_out, :status) "
450
+ "RETURNING id",
451
+ {"org": org_id, **event},
452
+ )
453
+ return {
454
+ "id": result.lastrowid,
455
+ "ts": event["ts"],
456
+ "licenseId": license_id,
457
+ "agentId": agent_id,
458
+ "endpoint": endpoint,
459
+ "tokensIn": event["tokens_in"],
460
+ "tokensOut": event["tokens_out"],
461
+ "status": event["status"],
462
+ }
463
+
464
+
465
+ def usage_summary(org_id: str, license_id: str | None = None) -> dict:
466
+ """Aggregate an org's metering rows (optionally for one license).
467
+
468
+ -> {calls, tokensIn, tokensOut, byAgent: {agentId: {calls, tokensIn,
469
+ tokensOut}}}. Values are plain ints on both dialects.
470
+ """
471
+ _ensure_migrations()
472
+ sql = (
473
+ "SELECT agent_id, COUNT(*) AS calls, "
474
+ " COALESCE(SUM(tokens_in), 0) AS tokens_in, "
475
+ " COALESCE(SUM(tokens_out), 0) AS tokens_out "
476
+ "FROM usage_events WHERE org_id = :org"
477
+ )
478
+ params: dict = {"org": org_id}
479
+ if license_id is not None:
480
+ sql += " AND license_id = :license_id"
481
+ params["license_id"] = license_id
482
+ sql += " GROUP BY agent_id"
483
+ by_agent: dict[str, dict] = {}
484
+ totals = {"calls": 0, "tokensIn": 0, "tokensOut": 0}
485
+ for r in tenant_db.scoped_query(org_id, sql, params):
486
+ entry = {
487
+ "calls": int(r["calls"]),
488
+ "tokensIn": int(r["tokens_in"]), # PG SUM β†’ Decimal
489
+ "tokensOut": int(r["tokens_out"]),
490
+ }
491
+ by_agent[r["agent_id"] or ""] = entry
492
+ totals["calls"] += entry["calls"]
493
+ totals["tokensIn"] += entry["tokensIn"]
494
+ totals["tokensOut"] += entry["tokensOut"]
495
+ return {**totals, "byAgent": by_agent}
496
+
497
+
498
+ def list_licenses(org_id: str) -> list[dict]:
499
+ """All of `org_id`'s licenses, latest-first, camelCase β€” never any key
500
+ material (only keyId, the public identifier half)."""
501
+ _ensure_migrations()
502
+ rows = tenant_db.scoped_query(
503
+ org_id,
504
+ f"SELECT {_LICENSE_SELECT} FROM licenses WHERE org_id = :org "
505
+ "ORDER BY created_at DESC, id DESC",
506
+ {"org": org_id},
507
+ )
508
+ return [_to_camel(r) for r in rows]
atp/reportcard.py ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ atp/reportcard.py β€” Phase 6 (docs/HARDENING.md): official report-card PDF.
3
+
4
+ build_report_card_pdf(agent_id, org_id='org-demo') -> bytes
5
+
6
+ Server-side PDF export of one agent's report card, written for a
7
+ NON-TECHNICAL stakeholder: who the agent is, what it is certified for,
8
+ where it is strong, and β€” deliberately IN the document, per the ATP
9
+ 'failure transparency' principle β€” where it will break.
10
+
11
+ Data sources:
12
+ * Seed catalog (atp/store.get_data()) β€” T0 public: agent profile,
13
+ reportCard prose, skills, failure modes, cert metadata + seed awards.
14
+ * Live cert awards for the agent from atp_cert_awards, org-scoped through
15
+ atp/tenant_db.py ONLY (docs/TENANCY.md layer 2; org_id comes from the
16
+ verified request state at the route). Live awards whose evidence was
17
+ produced by the deterministic dry-run backend are marked DRY-RUN so a
18
+ stub exam can never read like a real certification.
19
+
20
+ The PDF mirrors the Agent-view aesthetic (design/styles.css "university +
21
+ standards-body tone"): navy header band, gold rules, mono labels, layer
22
+ palette. reportlab only β€” built-in Helvetica/Courier faces, no external
23
+ assets. Rendering is pure (bytes out, no files written); tenant files are
24
+ NEVER involved and nothing is served from /videos or /media (TENANCY.md
25
+ leak surface #4).
26
+
27
+ Raises UnknownAgentError for an agent id that is not in the seed roster β€”
28
+ the API route maps it to 404.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import io
34
+ import json
35
+ import threading
36
+ import time
37
+ from xml.sax.saxutils import escape as _xml_escape
38
+
39
+ from reportlab.lib import colors
40
+ from reportlab.lib.pagesizes import A4
41
+ from reportlab.lib.styles import ParagraphStyle
42
+ from reportlab.lib.units import mm # noqa: F401 (kept: handy for tweaks)
43
+ from reportlab.platypus import (
44
+ Flowable,
45
+ HRFlowable,
46
+ Paragraph,
47
+ SimpleDocTemplate,
48
+ Spacer,
49
+ Table,
50
+ TableStyle,
51
+ )
52
+
53
+ from atp import store, tenant_db
54
+
55
+ # ── Palette β€” mirrors design/styles.css (ATP / Agent view tokens) ───────────
56
+
57
+ NAVY = colors.HexColor("#0a0e22") # --c-bg (header band)
58
+ NAVY_ELEV = colors.HexColor("#0f1733") # --c-bg-elev
59
+ GOLD = colors.HexColor("#e8c268") # --c-cert (on navy)
60
+ GOLD_PAPER = colors.HexColor("#a3801f") # --c-cert, paper palette (rules)
61
+ INK = colors.HexColor("#1c2237")
62
+ INK_MUTED = colors.HexColor("#5d6688")
63
+ LINE = colors.HexColor("#c9cede")
64
+ PANEL = colors.HexColor("#f4f5f9")
65
+ CORAL = colors.HexColor("#c2403f") # revoked / weak (paper-legible)
66
+ AMBER = colors.HexColor("#b45309") # developing
67
+ GREEN = colors.HexColor("#2f7d4a") # strong
68
+ GREY = colors.HexColor("#9ca3af") # untested
69
+
70
+ #: layer palette (--c-atp-l1..l7), darkened where needed for white paper.
71
+ LAYER_COLORS = {
72
+ 1: colors.HexColor("#2f7fae"), 2: colors.HexColor("#1f8a63"),
73
+ 3: colors.HexColor("#b45309"), 4: colors.HexColor("#c2410c"),
74
+ 5: colors.HexColor("#c2403f"), 6: colors.HexColor("#7c5cbf"),
75
+ 7: GOLD_PAPER,
76
+ }
77
+
78
+ _STATUS_COLORS = {
79
+ "strong": GREEN, "developing": AMBER, "weak": CORAL,
80
+ "untested": GREY, "active": GREEN, "training": AMBER, "revoked": CORAL,
81
+ }
82
+
83
+ PAGE_W, PAGE_H = A4
84
+ MARGIN = 48
85
+ BAND_H = 92 # first-page navy header band
86
+ BAND_H_LATER = 24 # thin band on later pages
87
+ FOOTER_H = 78
88
+
89
+ _MONO, _MONO_B = "Courier", "Courier-Bold"
90
+ _SANS, _SANS_B, _SANS_I = "Helvetica", "Helvetica-Bold", "Helvetica-Oblique"
91
+
92
+
93
+ class UnknownAgentError(ValueError):
94
+ """agent_id is not in the seed roster (atp/seed.py AGENTS)."""
95
+
96
+
97
+ # ── DB readiness (same lazy idiom as api/atp.py) ────────────────────────────
98
+
99
+ _DB_READY = False
100
+ _DB_LOCK = threading.Lock()
101
+
102
+
103
+ def _ensure_db() -> None:
104
+ global _DB_READY
105
+ if _DB_READY:
106
+ return
107
+ with _DB_LOCK:
108
+ if not _DB_READY:
109
+ from atp import db
110
+ db.run_migrations()
111
+ _DB_READY = True
112
+
113
+
114
+ # ── Data assembly ───────────────────────────────────────────────────────────
115
+
116
+ def _find_agent(data: dict, agent_id: str) -> dict:
117
+ for a in data.get("AGENTS", []):
118
+ if a.get("id") == agent_id:
119
+ return a
120
+ raise UnknownAgentError(
121
+ f"agent {agent_id!r} not found in the seed roster (atp/seed.py)")
122
+
123
+
124
+ def _seed_cert_rows(data: dict, agent_id: str) -> list[dict]:
125
+ """Seed awards for the agent: one row per cert grant, oldest first."""
126
+ rows = []
127
+ for cert in data.get("CERTS", []):
128
+ for award in cert.get("awards", []):
129
+ if award.get("agentId") != agent_id:
130
+ continue
131
+ rows.append({
132
+ "label": cert["label"],
133
+ "domain": cert["domain"],
134
+ "layer": cert["layer"],
135
+ "score": award.get("score"),
136
+ "ts": award.get("ts", ""),
137
+ "revoked": award.get("status") == "revoked",
138
+ "revokedTs": award.get("revokedTs"),
139
+ "live": False,
140
+ "dryRun": False,
141
+ })
142
+ rows.sort(key=lambda r: r["ts"])
143
+ return rows
144
+
145
+
146
+ def _loads_maybe(v):
147
+ if not isinstance(v, str):
148
+ return v
149
+ try:
150
+ return json.loads(v)
151
+ except (json.JSONDecodeError, ValueError):
152
+ return v
153
+
154
+
155
+ def _live_cert_rows(data: dict, agent_id: str, org_id: str) -> list[dict]:
156
+ """Live atp_cert_awards rows for the agent (org-scoped), oldest first.
157
+
158
+ DRY-RUN detection: an award's evidence rows (written in the same exam
159
+ run by atp/exams.py) record the candidate spec in their payload; if any
160
+ resolves to the deterministic dry-run backend ('dryrun:*'), the award is
161
+ marked so the table can never present a stub exam as a real one.
162
+ """
163
+ _ensure_db()
164
+ awards = tenant_db.scoped_query(
165
+ org_id,
166
+ "SELECT id, ts, cert_id, score, evidence_ids FROM atp_cert_awards"
167
+ " WHERE org_id = :org AND agent_id = :aid ORDER BY id ASC",
168
+ {"org": org_id, "aid": agent_id})
169
+ if not awards:
170
+ return []
171
+
172
+ ev_rows = tenant_db.scoped_query(
173
+ org_id,
174
+ "SELECT id, payload FROM atp_evidence"
175
+ " WHERE org_id = :org AND agent_id = :aid",
176
+ {"org": org_id, "aid": agent_id})
177
+ candidate_by_id: dict[str, str] = {}
178
+ for row in ev_rows:
179
+ payload = _loads_maybe(row.get("payload"))
180
+ if isinstance(payload, dict):
181
+ candidate_by_id[str(row["id"])] = str(payload.get("candidate", ""))
182
+
183
+ certs_by_id = {c["id"]: c for c in data.get("CERTS", [])}
184
+ out = []
185
+ for aw in awards:
186
+ cert = certs_by_id.get(aw.get("cert_id"), {})
187
+ evidence_ids = _loads_maybe(aw.get("evidence_ids")) or []
188
+ dry = any(
189
+ candidate_by_id.get(str(eid), "").startswith("dryrun")
190
+ for eid in evidence_ids)
191
+ out.append({
192
+ "label": cert.get("label", aw.get("cert_id", "?")),
193
+ "domain": cert.get("domain", "β€”"),
194
+ "layer": cert.get("layer", "β€”"),
195
+ "score": aw.get("score"),
196
+ # Live award ts is a full ISO stamp; the table shows the date
197
+ # (seed award dates are already date-only).
198
+ "ts": str(aw.get("ts") or "")[:10],
199
+ "revoked": False,
200
+ "revokedTs": None,
201
+ "live": True,
202
+ "dryRun": dry,
203
+ })
204
+ return out
205
+
206
+
207
+ def _live_evidence_count(agent_id: str, org_id: str) -> int:
208
+ _ensure_db()
209
+ rows = tenant_db.scoped_query(
210
+ org_id,
211
+ "SELECT COUNT(*) AS n FROM atp_evidence"
212
+ " WHERE org_id = :org AND agent_id = :aid",
213
+ {"org": org_id, "aid": agent_id})
214
+ return int(rows[0]["n"]) if rows else 0
215
+
216
+
217
+ def _citation(data: dict, agent: dict) -> str:
218
+ """The citable sentence (STANDARD.sentence grammar); honest on revoke."""
219
+ domain = (agent.get("domains") or ["β€”"])[0]
220
+ if agent.get("status") == "revoked":
221
+ return (f"{agent['name']} is NOT currently ATP-certified in {domain}: "
222
+ f"its L{agent['level']} certification was revoked.")
223
+ template = data.get("STANDARD", {}).get(
224
+ "sentence", "{agent} is an ATP L{n}-certified agent in {domain}.")
225
+ return (template.replace("{agent}", agent["name"])
226
+ .replace("{n}", str(agent["level"]))
227
+ .replace("{domain}", domain))
228
+
229
+
230
+ # ── Small flowables ─────────────────────────────────────────────────────────
231
+
232
+ class _MasteryBar(Flowable):
233
+ """Simple filled-rect mastery bar (per the UI's mastery meters)."""
234
+
235
+ def __init__(self, fraction: float, status: str,
236
+ width: float = 108, height: float = 7):
237
+ super().__init__()
238
+ self.fraction = max(0.0, min(1.0, float(fraction or 0.0)))
239
+ self.fill = _STATUS_COLORS.get(status, GREY)
240
+ self.width, self.height = width, height
241
+
242
+ def wrap(self, availWidth, availHeight): # noqa: N803 (reportlab API)
243
+ return self.width, self.height
244
+
245
+ def draw(self):
246
+ c = self.canv
247
+ c.setFillColor(PANEL)
248
+ c.rect(0, 0, self.width, self.height, stroke=0, fill=1)
249
+ if self.fraction > 0:
250
+ c.setFillColor(self.fill)
251
+ c.rect(0, 0, self.width * self.fraction, self.height,
252
+ stroke=0, fill=1)
253
+ c.setStrokeColor(LINE)
254
+ c.setLineWidth(0.5)
255
+ c.rect(0, 0, self.width, self.height, stroke=1, fill=0)
256
+
257
+
258
+ # ── Paragraph styles ────────────────────────────────────────────────────────
259
+
260
+ def _styles() -> dict[str, ParagraphStyle]:
261
+ return {
262
+ "h1": ParagraphStyle("h1", fontName=_SANS_B, fontSize=17,
263
+ leading=21, textColor=INK),
264
+ "section": ParagraphStyle("section", fontName=_MONO_B, fontSize=9.5,
265
+ leading=12, textColor=NAVY_ELEV,
266
+ spaceBefore=6),
267
+ "body": ParagraphStyle("body", fontName=_SANS, fontSize=9.5,
268
+ leading=13.5, textColor=INK),
269
+ "bullet": ParagraphStyle("bullet", fontName=_SANS, fontSize=9.5,
270
+ leading=13.5, textColor=INK,
271
+ leftIndent=10, bulletIndent=2),
272
+ "cell": ParagraphStyle("cell", fontName=_SANS, fontSize=8.5,
273
+ leading=11, textColor=INK),
274
+ "cellMono": ParagraphStyle("cellMono", fontName=_MONO, fontSize=8,
275
+ leading=10.5, textColor=INK),
276
+ "labelMono": ParagraphStyle("labelMono", fontName=_MONO, fontSize=7,
277
+ leading=9, textColor=INK_MUTED),
278
+ "note": ParagraphStyle("note", fontName=_SANS_I, fontSize=8.5,
279
+ leading=11.5, textColor=INK_MUTED),
280
+ }
281
+
282
+
283
+ def _esc(s) -> str:
284
+ return _xml_escape(str(s if s is not None else "β€”"))
285
+
286
+
287
+ def _rule(color=GOLD_PAPER, thickness=1.1):
288
+ return HRFlowable(width="100%", thickness=thickness, color=color,
289
+ spaceBefore=1, spaceAfter=6)
290
+
291
+
292
+ def _section(title: str, story: list, st: dict) -> None:
293
+ story.append(Spacer(1, 10))
294
+ story.append(Paragraph(_esc(title.upper()), st["section"]))
295
+ story.append(_rule())
296
+
297
+
298
+ # ── Page decoration (header band + footer, drawn on the canvas) ────────────
299
+
300
+ def _make_page_decorators(agent: dict, citation: str, n_evidence: int,
301
+ generated_at: str):
302
+ agent_name = agent.get("name", agent["id"])
303
+ deep_link = f"#/agent/{agent['id']}"
304
+
305
+ def _footer(canv, doc):
306
+ canv.saveState()
307
+ y = FOOTER_H - 20
308
+ canv.setStrokeColor(GOLD_PAPER)
309
+ canv.setLineWidth(1)
310
+ canv.line(MARGIN, y, PAGE_W - MARGIN, y)
311
+ canv.setFont(_SANS_I, 8)
312
+ canv.setFillColor(INK)
313
+ canv.drawString(MARGIN, y - 11, f"β€œ{citation}”")
314
+ canv.setFont(_MONO, 6.8)
315
+ canv.setFillColor(INK_MUTED)
316
+ canv.drawString(
317
+ MARGIN, y - 22,
318
+ f"{deep_link} Β· generated {generated_at} Β· "
319
+ f"evidence: {n_evidence} signed records β€” "
320
+ f"verify at /atp/chain/verify")
321
+ canv.setFont(_MONO, 7)
322
+ canv.drawRightString(PAGE_W - MARGIN, y - 11,
323
+ f"PAGE {canv.getPageNumber()}")
324
+ canv.restoreState()
325
+
326
+ def first_page(canv, doc):
327
+ canv.saveState()
328
+ canv.setFillColor(NAVY)
329
+ canv.rect(0, PAGE_H - BAND_H, PAGE_W, BAND_H, stroke=0, fill=1)
330
+ canv.setFillColor(GOLD)
331
+ canv.setFont(_MONO_B, 8.5)
332
+ canv.drawString(MARGIN, PAGE_H - 30,
333
+ "ATP β€” AGENTIC TRAINING PLATFORM")
334
+ canv.setFont(_MONO, 7)
335
+ canv.drawRightString(PAGE_W - MARGIN, PAGE_H - 30,
336
+ "STANDARD ATP@2026.1")
337
+ canv.setFillColor(colors.white)
338
+ canv.setFont(_SANS_B, 19)
339
+ canv.drawString(MARGIN, PAGE_H - 56, "Official Report Card")
340
+ canv.setFillColor(colors.HexColor("#9aa3c0"))
341
+ canv.setFont(_MONO, 7.5)
342
+ canv.drawString(MARGIN, PAGE_H - 72,
343
+ "EVIDENCE-LINKED Β· REPRODUCIBLE Β· "
344
+ "FAILURE-TRANSPARENT Β· REVOCABLE")
345
+ canv.setStrokeColor(GOLD)
346
+ canv.setLineWidth(2)
347
+ canv.line(0, PAGE_H - BAND_H, PAGE_W, PAGE_H - BAND_H)
348
+ canv.restoreState()
349
+ _footer(canv, doc)
350
+
351
+ def later_pages(canv, doc):
352
+ canv.saveState()
353
+ canv.setFillColor(NAVY)
354
+ canv.rect(0, PAGE_H - BAND_H_LATER, PAGE_W, BAND_H_LATER,
355
+ stroke=0, fill=1)
356
+ canv.setFillColor(GOLD)
357
+ canv.setFont(_MONO_B, 7.5)
358
+ canv.drawString(MARGIN, PAGE_H - 16,
359
+ f"ATP β€” OFFICIAL REPORT CARD Β· "
360
+ f"{agent_name.upper()}")
361
+ canv.setStrokeColor(GOLD)
362
+ canv.setLineWidth(1.2)
363
+ canv.line(0, PAGE_H - BAND_H_LATER, PAGE_W, PAGE_H - BAND_H_LATER)
364
+ canv.restoreState()
365
+ _footer(canv, doc)
366
+
367
+ return first_page, later_pages
368
+
369
+
370
+ # ── Story sections ──────────────────────────────────────────────────────────
371
+
372
+ def _identity_block(agent: dict, story: list, st: dict) -> None:
373
+ status = agent.get("status", "β€”")
374
+ status_color = _STATUS_COLORS.get(status, INK)
375
+ story.append(Paragraph(_esc(agent.get("name", agent["id"])), st["h1"]))
376
+ story.append(Spacer(1, 4))
377
+
378
+ labels = ["AGENT ID", "BASE MODEL", "LEVEL", "CAREER STAGE", "TIER",
379
+ "STATUS"]
380
+ values = [
381
+ Paragraph(_esc(agent["id"]), st["cellMono"]),
382
+ Paragraph(_esc(agent.get("baseModel")), st["cell"]),
383
+ Paragraph(
384
+ f'<font name="{_MONO_B}" color="{LAYER_COLORS.get(agent.get("level"), INK).hexval()}">'
385
+ f'L{_esc(agent.get("level"))}</font>', st["cell"]),
386
+ Paragraph(_esc(agent.get("careerStage")), st["cell"]),
387
+ Paragraph(_esc(agent.get("tier")), st["cell"]),
388
+ Paragraph(
389
+ f'<font name="{_SANS_B}" color="{status_color.hexval()}">'
390
+ f'{_esc(status.upper())}</font>', st["cell"]),
391
+ ]
392
+ # AGENT ID gets the widest column so long slugs (gemma-physics-ocw)
393
+ # never wrap mid-word in the mono face.
394
+ w = PAGE_W - 2 * MARGIN
395
+ col_w = [w * f for f in (0.21, 0.17, 0.10, 0.16, 0.15, 0.21)]
396
+ table = Table(
397
+ [[Paragraph(lbl, st["labelMono"]) for lbl in labels], values],
398
+ colWidths=col_w)
399
+ table.setStyle(TableStyle([
400
+ ("TOPPADDING", (0, 0), (-1, -1), 1),
401
+ ("BOTTOMPADDING", (0, 0), (-1, 0), 0),
402
+ ("BOTTOMPADDING", (0, 1), (-1, 1), 4),
403
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
404
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
405
+ ("LINEBELOW", (0, 1), (-1, 1), 0.5, LINE),
406
+ ]))
407
+ story.append(table)
408
+
409
+ prov = agent.get("provenance") or {}
410
+ if prov.get("origin") == "commissioned":
411
+ corpora = ", ".join(prov.get("corpora") or []) or "β€”"
412
+ req = prov.get("requestId") or "β€”"
413
+ story.append(Spacer(1, 5))
414
+ story.append(Paragraph(
415
+ f"PROVENANCE: COMMISSIONED (TRAIN-TO-ORDER) Β· "
416
+ f"CORPORA: {_esc(corpora)} Β· REQUEST: {_esc(req)}",
417
+ ParagraphStyle("prov", parent=st["cellMono"], fontSize=7.5,
418
+ textColor=GOLD_PAPER)))
419
+ story.append(Paragraph(
420
+ "This agent was trained to order for a specific corpus and "
421
+ "certified through the identical exam blueprint as every "
422
+ "pre-existing agent β€” there is no separate trust tier for "
423
+ "commissioned experts.", st["note"]))
424
+
425
+ if agent.get("status") == "revoked":
426
+ story.append(Spacer(1, 7))
427
+ warn = Table([[Paragraph(
428
+ f'<font name="{_SANS_B}" color="{CORAL.hexval()}">'
429
+ f'CERTIFICATION REVOKED.</font> '
430
+ f'The ATP board has revoked this agent’s badge; the record '
431
+ f'below stays public on purpose (the β€œrevocable” '
432
+ f'principle). See the certifications table and the failure '
433
+ f'transparency section before relying on this agent for '
434
+ f'anything.', st["cell"])]],
435
+ colWidths=[PAGE_W - 2 * MARGIN])
436
+ warn.setStyle(TableStyle([
437
+ ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#faeceb")),
438
+ ("BOX", (0, 0), (-1, -1), 0.8, CORAL),
439
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
440
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
441
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
442
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
443
+ ]))
444
+ story.append(warn)
445
+
446
+
447
+ def _report_card_block(agent: dict, story: list, st: dict) -> None:
448
+ rc = agent.get("reportCard") or {}
449
+ _section("Report card β€” in plain language", story, st)
450
+ if rc.get("summary"):
451
+ story.append(Paragraph(_esc(rc["summary"]), st["body"]))
452
+ story.append(Spacer(1, 6))
453
+ if rc.get("strengths"):
454
+ story.append(Paragraph("<b>Strengths</b>", st["body"]))
455
+ for s in rc["strengths"]:
456
+ story.append(Paragraph(_esc(s), st["bullet"],
457
+ bulletText="–"))
458
+ story.append(Spacer(1, 5))
459
+ if rc.get("weaknesses"):
460
+ story.append(Paragraph("<b>Weaknesses</b>", st["body"]))
461
+ for w in rc["weaknesses"]:
462
+ story.append(Paragraph(_esc(w), st["bullet"],
463
+ bulletText="–"))
464
+ story.append(Spacer(1, 5))
465
+ if rc.get("recommendation"):
466
+ rec = Table([[Paragraph(
467
+ f"<b>Recommendation</b> β€” {_esc(rc['recommendation'])}",
468
+ st["cell"])]], colWidths=[PAGE_W - 2 * MARGIN])
469
+ rec.setStyle(TableStyle([
470
+ ("BACKGROUND", (0, 0), (-1, -1), PANEL),
471
+ ("BOX", (0, 0), (-1, -1), 0.6, GOLD_PAPER),
472
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
473
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
474
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
475
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
476
+ ]))
477
+ story.append(rec)
478
+
479
+
480
+ def _cert_table(rows: list[dict], story: list, st: dict) -> None:
481
+ _section("Certifications", story, st)
482
+ if not rows:
483
+ story.append(Paragraph(
484
+ "No certifications on record. This agent has not yet passed an "
485
+ "ATP exam.", st["note"]))
486
+ return
487
+
488
+ header = ["CERTIFICATION", "DOMAIN", "LAYER", "SCORE", "DATE", "STATUS"]
489
+ data = [[Paragraph(h, st["labelMono"]) for h in header]]
490
+ styles = TableStyle([
491
+ ("BACKGROUND", (0, 0), (-1, 0), NAVY),
492
+ ("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
493
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
494
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
495
+ ("LEFTPADDING", (0, 0), (-1, -1), 5),
496
+ ("RIGHTPADDING", (0, 0), (-1, -1), 5),
497
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
498
+ ])
499
+ header_lbl = ParagraphStyle("hdr", parent=st["labelMono"],
500
+ textColor=GOLD)
501
+ data[0] = [Paragraph(h, header_lbl) for h in header]
502
+
503
+ for i, r in enumerate(rows, start=1):
504
+ layer_color = LAYER_COLORS.get(r["layer"], INK)
505
+ if r["revoked"]:
506
+ status_txt = (f'<font name="{_MONO_B}" color="{CORAL.hexval()}">'
507
+ f'REVOKED {_esc(r.get("revokedTs") or "")}</font>')
508
+ elif r["live"] and r["dryRun"]:
509
+ status_txt = (f'<font name="{_MONO_B}" '
510
+ f'color="{AMBER.hexval()}">LIVE Β· DRY-RUN'
511
+ f'</font>')
512
+ elif r["live"]:
513
+ status_txt = (f'<font name="{_MONO_B}" '
514
+ f'color="{GREEN.hexval()}">LIVE EXAM</font>')
515
+ else:
516
+ status_txt = (f'<font name="{_MONO}" '
517
+ f'color="{GREEN.hexval()}">granted</font>')
518
+ score = r.get("score")
519
+ data.append([
520
+ Paragraph(_esc(r["label"]), st["cellMono"]),
521
+ Paragraph(_esc(r["domain"]), st["cell"]),
522
+ Paragraph(
523
+ f'<font name="{_MONO_B}" color="{layer_color.hexval()}">'
524
+ f'L{_esc(r["layer"])}</font>', st["cell"]),
525
+ Paragraph("β€”" if score is None else f"{float(score):.2f}",
526
+ st["cellMono"]),
527
+ Paragraph(_esc(r["ts"]), st["cellMono"]),
528
+ Paragraph(status_txt, st["cellMono"]),
529
+ ])
530
+ if r["revoked"]:
531
+ styles.add("BACKGROUND", (0, i), (-1, i),
532
+ colors.HexColor("#faeceb"))
533
+
534
+ w = PAGE_W - 2 * MARGIN
535
+ table = Table(data, colWidths=[w * 0.30, w * 0.17, w * 0.08, w * 0.10,
536
+ w * 0.15, w * 0.20], repeatRows=1)
537
+ table.setStyle(styles)
538
+ story.append(table)
539
+ if any(r["live"] and r["dryRun"] for r in rows):
540
+ story.append(Spacer(1, 3))
541
+ story.append(Paragraph(
542
+ "DRY-RUN entries were produced by the deterministic CI stub "
543
+ "backend, not a live model β€” they demonstrate the exam "
544
+ "pipeline and are not real certifications.", st["note"]))
545
+
546
+
547
+ def _skills_table(agent: dict, story: list, st: dict) -> None:
548
+ _section("Skills β€” measured mastery", story, st)
549
+ skills = agent.get("skills") or []
550
+ if not skills:
551
+ story.append(Paragraph("No skills on record.", st["note"]))
552
+ return
553
+ header_lbl = ParagraphStyle("hdr2", parent=st["labelMono"],
554
+ textColor=GOLD)
555
+ data = [[Paragraph(h, header_lbl)
556
+ for h in ("SKILL", "LAYER", "MASTERY", "SCORE", "STATUS")]]
557
+ for s in skills:
558
+ status = s.get("status", "untested")
559
+ color = _STATUS_COLORS.get(status, GREY)
560
+ mastery = float(s.get("mastery") or 0.0)
561
+ data.append([
562
+ Paragraph(_esc(s.get("name")), st["cell"]),
563
+ Paragraph(
564
+ f'<font name="{_MONO}" '
565
+ f'color="{LAYER_COLORS.get(s.get("layer"), INK).hexval()}">'
566
+ f'L{_esc(s.get("layer"))}</font>', st["cell"]),
567
+ _MasteryBar(mastery, status),
568
+ Paragraph(f"{mastery:.2f}", st["cellMono"]),
569
+ Paragraph(
570
+ f'<font name="{_MONO}" color="{color.hexval()}">'
571
+ f'{_esc(status)}</font>', st["cellMono"]),
572
+ ])
573
+ w = PAGE_W - 2 * MARGIN
574
+ table = Table(data, colWidths=[w * 0.36, w * 0.09, w * 0.27, w * 0.10,
575
+ w * 0.18], repeatRows=1)
576
+ table.setStyle(TableStyle([
577
+ ("BACKGROUND", (0, 0), (-1, 0), NAVY),
578
+ ("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
579
+ ("TOPPADDING", (0, 0), (-1, -1), 3.5),
580
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3.5),
581
+ ("LEFTPADDING", (0, 0), (-1, -1), 5),
582
+ ("RIGHTPADDING", (0, 0), (-1, -1), 5),
583
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
584
+ ]))
585
+ story.append(table)
586
+ story.append(Spacer(1, 3))
587
+ story.append(Paragraph(
588
+ "β€œuntested” means exactly that: the skill has not been "
589
+ "measured yet. ATP shows uncertainty rather than hiding it.",
590
+ st["note"]))
591
+
592
+
593
+ def _failure_table(agent: dict, story: list, st: dict) -> None:
594
+ _section("Failure transparency β€” where this agent breaks", story, st)
595
+ story.append(Paragraph(
596
+ "The ATP standard publishes failure modes next to the passes. This "
597
+ "section is part of the official record β€” a badge tells you not "
598
+ "only what the agent can do, but concretely where it will fail.",
599
+ st["note"]))
600
+ story.append(Spacer(1, 4))
601
+ failures = agent.get("failures") or []
602
+ if not failures:
603
+ story.append(Paragraph(
604
+ "No failure modes recorded yet β€” usually a sign the agent "
605
+ "is too new to have been stress-tested, not that it is perfect.",
606
+ st["note"]))
607
+ return
608
+ header_lbl = ParagraphStyle("hdr3", parent=st["labelMono"],
609
+ textColor=GOLD)
610
+ data = [[Paragraph(h, header_lbl)
611
+ for h in ("FAILURE MODE", "RATE", "BOUNDARY β€” WHEN NOT TO "
612
+ "TRUST IT")]]
613
+ for f in failures:
614
+ rate = float(f.get("rate") or 0.0)
615
+ data.append([
616
+ Paragraph(f"<b>{_esc(f.get('mode'))}</b>", st["cell"]),
617
+ Paragraph(
618
+ f'<font name="{_MONO_B}" color="{CORAL.hexval()}">'
619
+ f'{rate:.0%}</font>', st["cellMono"]),
620
+ Paragraph(_esc(f.get("boundary")), st["cell"]),
621
+ ])
622
+ w = PAGE_W - 2 * MARGIN
623
+ table = Table(data, colWidths=[w * 0.27, w * 0.09, w * 0.64],
624
+ repeatRows=1)
625
+ table.setStyle(TableStyle([
626
+ ("BACKGROUND", (0, 0), (-1, 0), NAVY),
627
+ ("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
628
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
629
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
630
+ ("LEFTPADDING", (0, 0), (-1, -1), 5),
631
+ ("RIGHTPADDING", (0, 0), (-1, -1), 5),
632
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
633
+ ]))
634
+ story.append(table)
635
+
636
+
637
+ # ── Public builder ──────────────────────────────────────────────────────────
638
+
639
+ def build_report_card_pdf(agent_id: str,
640
+ org_id: str = tenant_db.DEFAULT_ORG) -> bytes:
641
+ """Render the official report-card PDF for `agent_id` β†’ PDF bytes.
642
+
643
+ Seed profile + prose from atp/store.get_data(); live awards/evidence for
644
+ the caller's org via atp/tenant_db.py. Raises UnknownAgentError when the
645
+ agent is not in the seed roster.
646
+ """
647
+ data = store.get_data()
648
+ agent = _find_agent(data, agent_id)
649
+
650
+ cert_rows = _seed_cert_rows(data, agent_id)
651
+ cert_rows += _live_cert_rows(data, agent_id, org_id)
652
+
653
+ seed_evidence = sum(
654
+ 1 for e in data.get("EVIDENCE", []) if e.get("agentId") == agent_id)
655
+ n_evidence = seed_evidence + _live_evidence_count(agent_id, org_id)
656
+
657
+ generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
658
+ citation = _citation(data, agent)
659
+ st = _styles()
660
+
661
+ buf = io.BytesIO()
662
+ doc = SimpleDocTemplate(
663
+ buf, pagesize=A4,
664
+ leftMargin=MARGIN, rightMargin=MARGIN,
665
+ topMargin=BAND_H + 22, bottomMargin=FOOTER_H,
666
+ title=f"ATP Official Report Card β€” {agent.get('name', agent_id)}",
667
+ author="ATP β€” Agentic Training Platform")
668
+
669
+ story: list = []
670
+ _identity_block(agent, story, st)
671
+ _report_card_block(agent, story, st)
672
+ _cert_table(cert_rows, story, st)
673
+ _skills_table(agent, story, st)
674
+ _failure_table(agent, story, st)
675
+
676
+ first_page, later_pages = _make_page_decorators(
677
+ agent, citation, n_evidence, generated_at)
678
+ doc.build(story, onFirstPage=first_page, onLaterPages=later_pages)
679
+ return buf.getvalue()
680
+
681
+
682
+ # ── CLI (dev convenience) ───────────────────────────────────────────────────
683
+
684
+ if __name__ == "__main__":
685
+ import argparse
686
+
687
+ parser = argparse.ArgumentParser(
688
+ description="Render an agent's official report-card PDF (Phase 6).")
689
+ parser.add_argument("agent_id", help="e.g. atlas-ops")
690
+ parser.add_argument("out", help="output .pdf path")
691
+ parser.add_argument("--org", default=tenant_db.DEFAULT_ORG)
692
+ args = parser.parse_args()
693
+
694
+ pdf = build_report_card_pdf(args.agent_id, org_id=args.org)
695
+ with open(args.out, "wb") as fh:
696
+ fh.write(pdf)
697
+ print(f"wrote {len(pdf)} bytes to {args.out}")
atp/seed.py ADDED
The diff for this file is too large to render. See raw diff
 
atp/signing.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evidence signing β€” HMAC-SHA256 over canonical payloads, per-org hash chain.
3
+
4
+ Phase 3 (docs/HARDENING.md): every evidence row written by atp/exams.py is
5
+ signed so the append-only tables (migration 002 triggers) become
6
+ tamper-EVIDENT, not just append-only:
7
+
8
+ sig = HMAC-SHA256(key, prev_hash || canonical(payload))
9
+ prev_hash = sig of the previous atp_evidence row for the SAME org
10
+ ('genesis' for an org's first row)
11
+
12
+ Editing any historical payload breaks that row's sig; deleting or reordering
13
+ rows breaks the prev_hash linkage β€” verify_chain() catches both.
14
+
15
+ Key resolution (fail-loud, mirrors api/auth.py's posture):
16
+
17
+ EVIDENCE_SIGNING_KEY required whenever signing/verification happens
18
+ with auth enabled β€” RuntimeError if unset.
19
+ BU_AUTH_DISABLED=1 (dev) fallback: derive a stable key from
20
+ BU_AUTH_SECRET when set, else a process-
21
+ ephemeral random key with a loud warning
22
+ (sigs then don't verify across restarts).
23
+
24
+ Public:
25
+ canonical(payload) -> bytes stable JSON bytes (sorted keys,
26
+ no whitespace drift)
27
+ sign(payload, prev_hash) -> str hex HMAC-SHA256
28
+ verify_row(row) -> bool recompute + constant-time compare
29
+ last_sig(org_id) -> str chain head for an org ('genesis'
30
+ when the org has no evidence yet)
31
+ verify_chain(org_id) -> dict {ok, length, first_bad} walking
32
+ atp_evidence in id order (org-scoped
33
+ via atp/tenant_db.py)
34
+ GENESIS the chain-root sentinel string
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import hmac
41
+ import json
42
+ import os
43
+ import secrets
44
+ import sys
45
+ import threading
46
+
47
+ from sqlalchemy import text
48
+
49
+ from atp import db, tenant_db
50
+
51
+ #: prev_hash sentinel for the first evidence row of an org's chain.
52
+ GENESIS = "genesis"
53
+
54
+ #: Domain-separation tag for the BU_AUTH_SECRET-derived dev key.
55
+ _DERIVE_TAG = b"bu-evidence-signing-v1:"
56
+
57
+ _EPHEMERAL_KEY: bytes | None = None
58
+ _WARNED = False
59
+
60
+
61
+ class SigningKeyError(RuntimeError):
62
+ """Signing was requested but no usable key is configured."""
63
+
64
+
65
+ def _signing_key() -> bytes:
66
+ """Resolve the HMAC key (see module docstring for the precedence)."""
67
+ global _EPHEMERAL_KEY, _WARNED
68
+ key = os.environ.get("EVIDENCE_SIGNING_KEY", "").strip()
69
+ if key:
70
+ return key.encode("utf-8")
71
+
72
+ if os.environ.get("BU_AUTH_DISABLED", "") == "1":
73
+ # Dev mode: never block the loop, but never sign with a hardcoded
74
+ # default either β€” derive from the auth secret when one exists.
75
+ auth_secret = os.environ.get("BU_AUTH_SECRET", "").strip()
76
+ if auth_secret:
77
+ return hashlib.sha256(_DERIVE_TAG + auth_secret.encode("utf-8")).digest()
78
+ if _EPHEMERAL_KEY is None:
79
+ _EPHEMERAL_KEY = secrets.token_bytes(32)
80
+ if not _WARNED:
81
+ _WARNED = True
82
+ print(
83
+ "WARNING: EVIDENCE_SIGNING_KEY unset (BU_AUTH_DISABLED=1 dev "
84
+ "mode) β€” signing evidence with a process-EPHEMERAL key. "
85
+ "Signatures will NOT verify after a restart. Set "
86
+ "EVIDENCE_SIGNING_KEY (see .env.example).",
87
+ file=sys.stderr,
88
+ )
89
+ return _EPHEMERAL_KEY
90
+
91
+ raise SigningKeyError(
92
+ "EVIDENCE_SIGNING_KEY is not set. Evidence signing is mandatory when "
93
+ "auth is enabled (docs/HARDENING.md Phase 3) β€” generate one with "
94
+ "python3 -c \"import secrets; print(secrets.token_hex(32))\" and "
95
+ "export it, or export BU_AUTH_DISABLED=1 for local dev only."
96
+ )
97
+
98
+
99
+ # ── Canonicalisation + signing ───────────────────────────────────────────────
100
+
101
+ def canonical(payload: dict) -> bytes:
102
+ """Stable byte serialisation of a payload dict.
103
+
104
+ json.dumps with sorted keys, compact separators, and ensure_ascii=True:
105
+ key order, whitespace, and non-ASCII escaping can never drift between the
106
+ signer and a later verifier (including across Python versions).
107
+ """
108
+ return json.dumps(
109
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
110
+ ).encode("utf-8")
111
+
112
+
113
+ def sign(payload: dict, prev_hash: str) -> str:
114
+ """Hex HMAC-SHA256 over prev_hash || canonical(payload)."""
115
+ msg = (prev_hash or GENESIS).encode("utf-8") + canonical(payload)
116
+ return hmac.new(_signing_key(), msg, hashlib.sha256).hexdigest()
117
+
118
+
119
+ def verify_row(row: dict) -> bool:
120
+ """Recompute a row's sig from its stored payload + prev_hash.
121
+
122
+ `row` needs keys payload (JSON text or dict), sig, prev_hash β€” i.e. an
123
+ atp_evidence row as returned by the DAL. Constant-time compare.
124
+ """
125
+ payload = row.get("payload")
126
+ if isinstance(payload, (str, bytes)):
127
+ try:
128
+ payload = json.loads(payload)
129
+ except (ValueError, TypeError):
130
+ return False
131
+ if not isinstance(payload, dict):
132
+ return False
133
+ expected = sign(payload, row.get("prev_hash") or GENESIS)
134
+ return hmac.compare_digest(expected, str(row.get("sig") or ""))
135
+
136
+
137
+ # ── Chain access (org-scoped, via the tenant DAL only) ──────────────────────
138
+
139
+ def last_sig(org_id: str) -> str:
140
+ """Current chain head for an org: sig of its newest evidence row,
141
+ or GENESIS when the org has no evidence yet."""
142
+ rows = tenant_db.scoped_query(
143
+ org_id,
144
+ "SELECT sig FROM atp_evidence WHERE org_id = :org "
145
+ "ORDER BY id DESC LIMIT 1",
146
+ {"org": org_id},
147
+ )
148
+ return rows[0]["sig"] if rows and rows[0].get("sig") else GENESIS
149
+
150
+
151
+ # ── Serialized chain append (the ONLY sanctioned way to write evidence) ─────
152
+
153
+ _CHAIN_LOCKS: dict[str, threading.Lock] = {}
154
+ _CHAIN_LOCKS_GUARD = threading.Lock()
155
+
156
+
157
+ def _chain_lock(org_id: str) -> threading.Lock:
158
+ with _CHAIN_LOCKS_GUARD:
159
+ return _CHAIN_LOCKS.setdefault(org_id, threading.Lock())
160
+
161
+
162
+ def append_chain(org_id: str, records: list[dict]) -> list[tuple[int, str]]:
163
+ """Append `records` to an org's evidence chain atomically, in order.
164
+
165
+ records: [{ts, agent_id, cert_id, kind, payload(dict)}, ...]
166
+ returns: [(row_id, sig), ...] in the same order.
167
+
168
+ Why this exists: the chain head (prev_hash) must be read and written
169
+ under mutual exclusion or two concurrent exam runs interleave and
170
+ permanently fork the ledger. Serialization is layered:
171
+ - per-org threading.Lock β€” serializes writers within one process
172
+ (the jobs pool runs multiple worker threads);
173
+ - Postgres: pg_advisory_xact_lock(hashtext(org)) inside the same
174
+ transaction β€” serializes across processes AND instances;
175
+ - SQLite: single-file DB, and multi-process SQLite is already
176
+ unsupported (Dockerfile: WORKERS>1 requires Postgres).
177
+ The whole batch is one transaction, so an exam's evidence lands
178
+ all-or-nothing β€” no partially signed runs on crash.
179
+ """
180
+ out: list[tuple[int, str]] = []
181
+ with _chain_lock(org_id):
182
+ with tenant_db.scoped_txn(org_id) as conn:
183
+ if db.is_postgres():
184
+ conn.execute(
185
+ text("SELECT pg_advisory_xact_lock(hashtext(:org))"),
186
+ {"org": org_id},
187
+ )
188
+ row = conn.execute(
189
+ text("SELECT sig FROM atp_evidence WHERE org_id = :org "
190
+ "ORDER BY id DESC LIMIT 1"),
191
+ {"org": org_id},
192
+ ).first()
193
+ prev = row[0] if row and row[0] else GENESIS
194
+ for rec in records:
195
+ sig = sign(rec["payload"], prev)
196
+ r = conn.execute(
197
+ text("INSERT INTO atp_evidence "
198
+ " (ts, agent_id, cert_id, kind, payload, sig, "
199
+ " prev_hash, org_id) "
200
+ "VALUES (:ts, :agent_id, :cert_id, :kind, :payload, "
201
+ " :sig, :prev_hash, :org) "
202
+ "RETURNING id"),
203
+ {
204
+ "ts": rec["ts"],
205
+ "agent_id": rec["agent_id"],
206
+ "cert_id": rec["cert_id"],
207
+ "kind": rec["kind"],
208
+ "payload": canonical(rec["payload"]).decode("utf-8"),
209
+ "sig": sig,
210
+ "prev_hash": prev,
211
+ "org": org_id,
212
+ },
213
+ ).first()
214
+ out.append((r[0], sig))
215
+ prev = sig
216
+ return out
217
+
218
+
219
+ def verify_chain(org_id: str) -> dict:
220
+ """Walk an org's whole evidence chain in id order.
221
+
222
+ Returns {ok: bool, length: int, first_bad: int | None} where first_bad is
223
+ the id of the first row whose sig doesn't verify OR whose prev_hash
224
+ doesn't equal the previous row's sig (GENESIS for the first row).
225
+ """
226
+ rows = tenant_db.scoped_query(
227
+ org_id,
228
+ "SELECT id, payload, sig, prev_hash FROM atp_evidence "
229
+ "WHERE org_id = :org ORDER BY id ASC",
230
+ {"org": org_id},
231
+ )
232
+ prev = GENESIS
233
+ for row in rows:
234
+ if (row.get("prev_hash") or GENESIS) != prev or not verify_row(row):
235
+ return {"ok": False, "length": len(rows), "first_bad": row["id"]}
236
+ prev = row["sig"]
237
+ return {"ok": True, "length": len(rows), "first_bad": None}
atp/store.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ATP store β€” seed access plus the two append-only logs.
3
+
4
+ get_data() β†’ the full ATP_DATA dict (seed, cached)
5
+ append_hitl(event) -> event β†’ enrich with weightedDelta + ts + auditId,
6
+ insert into the atp_hitl table
7
+ read_hitl(limit=100) β†’ HITL log, latest first
8
+ append_request(payload) -> req β†’ compute status via the compose matching
9
+ rule, build pipeline stages, insert into
10
+ the atp_requests table
11
+ read_requests(limit=100) β†’ request log, latest first
12
+
13
+ Phase 1 (docs/HARDENING.md): both logs live in DAL tables (atp_hitl /
14
+ atp_requests β€” migration 002; append-only enforced by DB triggers) instead of
15
+ JSONL on ephemeral disk. Public shapes are unchanged: the same camelCase dict
16
+ keys (snake_case columns mapped back, JSON-text columns decoded) and read_*
17
+ still return latest-first lists. Legacy data/sessions/atp_hitl.jsonl /
18
+ atp_requests.jsonl are imported once into their empty table on first access,
19
+ then renamed to *.jsonl.imported (idempotent; the rename doubles as the
20
+ cross-process claim so rows land exactly once).
21
+
22
+ Phase 2 (docs/TENANCY.md): the four log functions take an OPTIONAL
23
+ `org_id` keyword (default `'org-demo'`, the bootstrap org every pre-tenancy
24
+ row belongs to β€” migration 005's column default). All table access is routed
25
+ through atp/tenant_db.py: writes stamp org_id, reads add `WHERE org_id =
26
+ :org`, and on Postgres every statement runs under `SET LOCAL app.org_id`
27
+ (RLS). Existing callers that don't pass org_id keep the exact pre-tenancy
28
+ behaviour against the demo org β€” signatures and returned shapes unchanged
29
+ (returned dicts do NOT grow an org key). Legacy JSONL imports land in
30
+ 'org-demo'.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import threading
37
+ import time
38
+ import uuid
39
+ from pathlib import Path
40
+
41
+ from sqlalchemy import inspect as sa_inspect
42
+
43
+ from . import tenant_db
44
+ from .seed import build_seed
45
+ from .tenant_db import DEFAULT_ORG
46
+
47
+ PROJECT_ROOT = Path(__file__).parent.parent
48
+ SESSIONS_DIR = PROJECT_ROOT / "data" / "sessions"
49
+ LEGACY_HITL_LOG = SESSIONS_DIR / "atp_hitl.jsonl" # pre-Phase-1 log
50
+ LEGACY_REQUESTS_LOG = SESSIONS_DIR / "atp_requests.jsonl" # pre-Phase-1 log
51
+
52
+ _DATA: dict | None = None
53
+
54
+
55
+ # ── Seed access ─────────────────────────────────────────────────────────────
56
+
57
+ def get_data() -> dict:
58
+ """Return the full ATP_DATA payload. Cached β€” seed is pure data."""
59
+ global _DATA
60
+ if _DATA is None:
61
+ _DATA = build_seed()
62
+ return _DATA
63
+
64
+
65
+ # ── HITL reward log (Β§1 formula) ────────────────────────────────────────────
66
+
67
+ def append_hitl(event: dict, org_id: str = DEFAULT_ORG) -> dict:
68
+ """Enrich a HITL reward signal and append it to the audit table.
69
+
70
+ Input: {agentId, layer, signal (+1|-1), reason, rater}
71
+ Adds: ts, weightedDelta, auditId
72
+ Formula (Β§1): weightedDelta = signal Γ— 0.2 Γ— (weight[layer] / 0.26)
73
+ The row is stamped with `org_id` (docs/TENANCY.md); the returned dict
74
+ keeps its pre-tenancy shape.
75
+ """
76
+ data = get_data()
77
+ weights = data["RL"]["rewardWeights"]
78
+
79
+ layer = int(event.get("layer", 1))
80
+ signal = int(event.get("signal", 0))
81
+ weight = float(weights.get(f"L{layer}", 0.0))
82
+ weighted_delta = round(signal * 0.2 * (weight / 0.26), 5)
83
+
84
+ enriched = {
85
+ "ts": event.get("ts") or _now(),
86
+ "rater": event.get("rater", "anonymous"),
87
+ "agentId": event.get("agentId"),
88
+ "layer": layer,
89
+ "signal": signal,
90
+ "weightedDelta": weighted_delta,
91
+ "reason": event.get("reason", ""),
92
+ "auditId": f"audit-{uuid.uuid4().hex[:10]}",
93
+ }
94
+ _ensure_ready("atp_hitl", LEGACY_HITL_LOG, _HITL_COLS)
95
+ tenant_db.insert_scoped("atp_hitl", _to_row(enriched, _HITL_COLS), org_id)
96
+ return enriched
97
+
98
+
99
+ def read_hitl(limit: int = 100, org_id: str = DEFAULT_ORG) -> list[dict]:
100
+ """Return `org_id`'s HITL log, latest first (capped at `limit`)."""
101
+ _ensure_ready("atp_hitl", LEGACY_HITL_LOG, _HITL_COLS)
102
+ return _read_latest("atp_hitl", _HITL_COLS, limit, org_id)
103
+
104
+
105
+ # ── Compose / train-to-order requests (Β§7.2) ────────────────────────────────
106
+
107
+ def append_request(payload: dict, org_id: str = DEFAULT_ORG) -> dict:
108
+ """Create an expert-composition request from a wizard submission.
109
+
110
+ Input: {major, specialty, badgeIds, packId}
111
+ Computes status against the seed agents with the same matching rule as
112
+ the client-side `matchExperts`, builds the pipeline stages, appends to
113
+ the request table (stamped with `org_id`), and returns the request.
114
+ """
115
+ data = get_data()
116
+ badge_ids = list(payload.get("badgeIds") or [])
117
+ pack_id = payload.get("packId")
118
+ major = str(payload.get("major") or "")
119
+
120
+ status, matched_agent_id, gap = _match(
121
+ data, major, badge_ids, pack_id,
122
+ # Wizard sends the concentration id as `specialty` (Β§7.2 payload);
123
+ # accept an explicit concentrationId too for parity with matchExperts.
124
+ payload.get("concentrationId") or payload.get("specialty") or None)
125
+ pipeline = _pipeline_for(status)
126
+
127
+ req = {
128
+ "id": f"req-{uuid.uuid4().hex[:8]}",
129
+ "ts": _now(),
130
+ "major": major,
131
+ "specialty": payload.get("specialty", ""),
132
+ "badgeIds": badge_ids,
133
+ "packId": pack_id,
134
+ "layers": list(payload.get("layers") or []),
135
+ "corpora": list(payload.get("corpora") or []),
136
+ "status": status,
137
+ "matchedAgentId": matched_agent_id,
138
+ "gap": gap,
139
+ "pipeline": pipeline,
140
+ "resultAgentId": None,
141
+ }
142
+ _ensure_ready("atp_requests", LEGACY_REQUESTS_LOG, _REQUEST_COLS)
143
+ tenant_db.insert_scoped("atp_requests", _to_row(req, _REQUEST_COLS), org_id)
144
+ return req
145
+
146
+
147
+ def read_requests(limit: int = 100, org_id: str = DEFAULT_ORG) -> list[dict]:
148
+ """Return `org_id`'s request log, latest first (capped at `limit`)."""
149
+ _ensure_ready("atp_requests", LEGACY_REQUESTS_LOG, _REQUEST_COLS)
150
+ return _read_latest("atp_requests", _REQUEST_COLS, limit, org_id)
151
+
152
+
153
+ # ── Matching rule (mirrors atp_data.js matchExperts) ────────────────────────
154
+
155
+ def _match(data: dict, major: str, badge_ids: list[str], pack_id, concentration_id=None):
156
+ """Return (status, matchedAgentId, gap) for a composition request.
157
+
158
+ Mirrors the client-side `matchExperts`: competencies come from the named
159
+ concentration if `concentration_id` is given, otherwise the union of every
160
+ concentration in the major.
161
+
162
+ exact = agent whose skills cover all of those competencies AND holds every
163
+ requested badge AND (packId ? bound to pack policies : true)
164
+ partial β†’ gap analysis; else training (commission).
165
+ """
166
+ # Resolve the concentration tolerantly: by id ('18-C') or display name
167
+ # ('Applied Mathematics', case-insensitive prefix). Unresolvable text
168
+ # (free-form specialty) falls back to the whole-major union rather than
169
+ # an empty competency set.
170
+ want = str(concentration_id).strip().lower() if concentration_id else ""
171
+ resolved = None
172
+ majors = [m for m in data["COMPOSE"]["majors"] if str(m["course"]) == str(major)]
173
+ if want:
174
+ for m in majors:
175
+ for cc in m["concentrations"]:
176
+ nm = cc.get("name", "").lower()
177
+ if cc["id"].lower() == want or nm == want or (nm and want.startswith(nm)):
178
+ resolved = cc["id"]
179
+ break
180
+ if resolved:
181
+ break
182
+
183
+ comps: list[str] = []
184
+ for m in majors:
185
+ for cc in m["concentrations"]:
186
+ if resolved and cc["id"] != resolved:
187
+ continue
188
+ comps.extend(cc.get("competencies", []))
189
+ comps = list(dict.fromkeys(comps)) # de-dup, keep order
190
+
191
+ pack_policies: list[str] = []
192
+ if pack_id:
193
+ pk = next((p for p in data["COMPOSE"]["packs"] if p["id"] == pack_id), None)
194
+ if pk:
195
+ pack_policies = list(pk.get("policyIds", []))
196
+
197
+ exact: list[str] = []
198
+ partials: list[dict] = []
199
+ for a in data["AGENTS"]:
200
+ if a.get("status") == "revoked":
201
+ continue
202
+ skill_names = {s["name"] for s in a.get("skills", [])}
203
+ cert_ids = set(a.get("certIds", []))
204
+ policy_ids = set(a.get("policyIds", []))
205
+
206
+ covered = [c for c in comps if c in skill_names]
207
+ missing = [c for c in comps if c not in skill_names]
208
+ holds_badges = all(b in cert_ids for b in badge_ids)
209
+ bound_pack = (not pack_id) or all(p in policy_ids for p in pack_policies)
210
+
211
+ if comps and not missing and holds_badges and bound_pack:
212
+ exact.append(a["id"])
213
+ elif covered or any(b in cert_ids for b in badge_ids):
214
+ partials.append({"agentId": a["id"], "covered": covered, "missing": missing})
215
+
216
+ if exact:
217
+ return "matched", exact[0], None
218
+
219
+ if partials:
220
+ partials.sort(key=lambda p: len(p["covered"]), reverse=True)
221
+ covered = sorted({c for p in partials for c in p["covered"]})
222
+ missing = [c for c in comps if c not in covered]
223
+ gap = {"covered": covered, "missing": missing,
224
+ "coveringAgentIds": [p["agentId"] for p in partials[:3]]}
225
+ return "gap", None, gap
226
+
227
+ return "training", None, None
228
+
229
+
230
+ def _pipeline_for(status: str) -> list[dict]:
231
+ stages = ["requested", "corpus-mapped", "fine-tuning", "certifying", "listed"]
232
+ if status == "matched":
233
+ done = {s: True for s in stages} # existing asset already covers it
234
+ elif status == "gap":
235
+ done = {"requested": True}
236
+ else: # training / commission
237
+ done = {"requested": True, "corpus-mapped": True}
238
+ now = _now()
239
+ return [{"stage": s, "ts": now if done.get(s) else None, "done": bool(done.get(s))}
240
+ for s in stages]
241
+
242
+
243
+ # ── DAL access (atp/tenant_db.py β€” migrations 002 + 005) ────────────────────
244
+
245
+ # dict key ↔ table column; declaration order = INSERT/SELECT column order.
246
+ _HITL_COLS = {
247
+ "ts": "ts",
248
+ "rater": "rater",
249
+ "agentId": "agent_id",
250
+ "layer": "layer",
251
+ "signal": "signal",
252
+ "weightedDelta": "weighted_delta",
253
+ "reason": "reason",
254
+ "auditId": "audit_id",
255
+ }
256
+ _REQUEST_COLS = {
257
+ "id": "id",
258
+ "ts": "ts",
259
+ "major": "major",
260
+ "specialty": "specialty",
261
+ "badgeIds": "badge_ids",
262
+ "packId": "pack_id",
263
+ "layers": "layers",
264
+ "corpora": "corpora",
265
+ "status": "status",
266
+ "matchedAgentId": "matched_agent_id",
267
+ "gap": "gap",
268
+ "pipeline": "pipeline",
269
+ "resultAgentId": "result_agent_id",
270
+ }
271
+ _JSON_COLS = {"badge_ids", "layers", "corpora", "gap", "pipeline"} # JSON text
272
+
273
+ _DB_LOCK = threading.Lock()
274
+ _DB_READY = False
275
+ _LEGACY_DONE: set[str] = set()
276
+ _ORDER: dict[str, str] = {}
277
+
278
+
279
+ def _engine():
280
+ """Engine from the Phase-1 DAL. Imported lazily so this module (and
281
+ `import api.server`) stays importable before atp/db.py lands; first log
282
+ access also ensures migrations ran β€” the server runs them at startup too,
283
+ and the runner is idempotent, so a second call is a no-op."""
284
+ global _DB_READY
285
+ from atp import db # deferred: see docstring
286
+
287
+ if not _DB_READY:
288
+ with _DB_LOCK:
289
+ if not _DB_READY:
290
+ fn = (getattr(db, "ensure_migrations", None)
291
+ or getattr(db, "run_migrations", None)
292
+ or getattr(db, "migrate", None))
293
+ if callable(fn):
294
+ try:
295
+ fn()
296
+ except TypeError: # runner variant that takes the engine
297
+ fn(db.get_engine())
298
+ _DB_READY = True
299
+ return db.get_engine()
300
+
301
+
302
+ def _ensure_ready(table: str, legacy: Path, cols: dict) -> None:
303
+ """Engine + migrations up, then the one-time legacy JSONL import."""
304
+ _engine()
305
+ _import_legacy_once(table, legacy, cols)
306
+
307
+
308
+ def _read_latest(table: str, cols: dict, limit: int, org_id: str) -> list[dict]:
309
+ sel = ", ".join(cols.values())
310
+ rows = tenant_db.scoped_query(
311
+ org_id,
312
+ f"SELECT {sel} FROM {table} WHERE org_id = :org"
313
+ f" ORDER BY {_order_by(table)} LIMIT :n",
314
+ {"org": org_id, "n": int(limit)})
315
+ return [_to_dict(r, cols) for r in rows]
316
+
317
+
318
+ def _order_by(table: str) -> str:
319
+ """Newest-first ordering. Prefer the monotonic surrogate key from
320
+ migration 002 (`seq`), tolerate an integer `id` pk, then fall back to
321
+ sqlite's implicit rowid / the ts column so reads survive DDL drift."""
322
+ cached = _ORDER.get(table)
323
+ if cached:
324
+ return cached
325
+ engine = _engine()
326
+ info = {c["name"]: str(c["type"]).upper() for c in sa_inspect(engine).get_columns(table)}
327
+ if "seq" in info:
328
+ order = "seq DESC"
329
+ elif "INT" in info.get("id", ""):
330
+ order = "id DESC"
331
+ elif engine.dialect.name == "sqlite":
332
+ order = "rowid DESC"
333
+ else:
334
+ order = "ts DESC"
335
+ _ORDER[table] = order
336
+ return order
337
+
338
+
339
+ def _to_row(rec: dict, cols: dict) -> dict:
340
+ """camelCase log dict β†’ snake_case column dict (JSON cols dumped)."""
341
+ row = {}
342
+ for key, col in cols.items():
343
+ v = rec.get(key)
344
+ if col in _JSON_COLS and v is not None:
345
+ v = json.dumps(v, default=str)
346
+ row[col] = v
347
+ return row
348
+
349
+
350
+ def _to_dict(row: dict, cols: dict) -> dict:
351
+ """DB row (scoped_query dict) β†’ the exact dict shape the JSONL logs used
352
+ (camelCase keys β€” org_id is intentionally NOT part of the shape)."""
353
+ rec = {}
354
+ for key, col in cols.items():
355
+ v = row[col]
356
+ if col in _JSON_COLS and v is not None:
357
+ v = json.loads(v)
358
+ rec[key] = v
359
+ return rec
360
+
361
+
362
+ def _import_legacy_once(table: str, legacy: Path, cols: dict) -> None:
363
+ """One-time lazy migration of the pre-Phase-1 JSONL log.
364
+
365
+ If the legacy file exists and the table holds no 'org-demo' rows, rename
366
+ the file to *.jsonl.imported (the atomic rename is the cross-process
367
+ claim β€” a racing worker gets FileNotFoundError and skips) and import its
368
+ rows oldest-first so surrogate-key order matches insertion order.
369
+ Pre-tenancy rows belong to the bootstrap org, so imports are stamped
370
+ 'org-demo' (docs/TENANCY.md leak surface #1). Idempotent: reruns see
371
+ either no file or existing rows and do nothing.
372
+ """
373
+ if table in _LEGACY_DONE:
374
+ return
375
+ with _DB_LOCK:
376
+ if table in _LEGACY_DONE:
377
+ return
378
+ if legacy.exists():
379
+ count = tenant_db.scoped_query(
380
+ DEFAULT_ORG,
381
+ f"SELECT COUNT(*) AS n FROM {table} WHERE org_id = :org",
382
+ {"org": DEFAULT_ORG})[0]["n"]
383
+ if not count:
384
+ claimed = legacy.with_name(legacy.name + ".imported")
385
+ try:
386
+ legacy.rename(claimed)
387
+ except FileNotFoundError:
388
+ claimed = None # another worker claimed the import
389
+ if claimed:
390
+ for line in claimed.read_text().splitlines():
391
+ line = line.strip()
392
+ if not line:
393
+ continue
394
+ try:
395
+ rec = json.loads(line)
396
+ except json.JSONDecodeError:
397
+ continue # same tolerance the JSONL reader had
398
+ tenant_db.insert_scoped(
399
+ table, _to_row(rec, cols), DEFAULT_ORG)
400
+ _LEGACY_DONE.add(table)
401
+
402
+
403
+ # ── Helpers ──────────────────────────────────────────────────────────────────
404
+
405
+ def _now() -> str:
406
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
atp/tenant_db.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Org-scoped DAL β€” enforcement layer 2 of docs/TENANCY.md (Phase 2).
3
+
4
+ Tenant tables (atp_hitl, atp_requests, atp_evidence, atp_cert_awards, jobs,
5
+ access_log) must be read/written EXCLUSIVELY through the helpers here β€” never
6
+ through raw atp.db query()/execute() from routes (convention + CI grep, per
7
+ TENANCY.md layer 2).
8
+
9
+ What the helpers enforce:
10
+
11
+ * Scope validation (both dialects): any statement that mentions a tenant
12
+ table must (a) bind an `:org` parameter in its SQL and (b) pass
13
+ params['org'] equal to the org_id argument β€” otherwise TenantScopeError.
14
+ The check is a deliberately SIMPLE parse (word-boundary table-name match);
15
+ false positives (a tenant table named in a string literal) just demand an
16
+ `:org` bind, never weaker scoping.
17
+ * Postgres (layer 3): every statement runs inside a transaction that first
18
+ executes `SELECT set_config('app.org_id', :org, true)` β€” the exact
19
+ equivalent of `SET LOCAL app.org_id` but bind-parameter safe. Migration
20
+ 005 enables FORCE ROW LEVEL SECURITY with policy
21
+ `USING (org_id = current_setting('app.org_id', true))`, so a transaction
22
+ that skips this wrapper sees ZERO tenant rows (deny by default β€” there is
23
+ intentionally no permissive fallback when the setting is absent).
24
+ * SQLite (dev/demo): no RLS β€” the `WHERE org_id = :org` the validation
25
+ forces into every statement IS the isolation (layers 1-2 only,
26
+ documented gap).
27
+
28
+ The org_id passed in must come from the verified JWT (route layer) β€” never
29
+ from a request body or query string.
30
+
31
+ Public:
32
+ scoped_query(org_id, sql, params) -> list[dict] (SELECTs)
33
+ scoped_execute(org_id, sql, params) -> db.ExecResult (writes)
34
+ insert_scoped(table, row, org_id) -> db.ExecResult (row['org_id'] forced)
35
+ log_access(org_id, user, role, method, path, table, row_count, status)
36
+ TENANT_TABLES, DEFAULT_ORG, TenantScopeError
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ import time
43
+ from contextlib import contextmanager
44
+
45
+ from sqlalchemy import text
46
+
47
+ from atp import db
48
+
49
+ #: Bootstrap org every pre-tenancy row belongs to (migration 005 default).
50
+ DEFAULT_ORG = "org-demo"
51
+
52
+ #: Tables that carry org_id and may only be touched through this module.
53
+ TENANT_TABLES = frozenset({
54
+ "atp_hitl",
55
+ "atp_requests",
56
+ "atp_evidence",
57
+ "atp_cert_awards",
58
+ "jobs",
59
+ "access_log",
60
+ "licenses", # Phase 4 (migration 006) β€” atp/licensing.py; the
61
+ "usage_events", # key_lookup read path is documented in _load_by_key_id
62
+ "vault_docs", # Phase 6 (migration 007) β€” atp/knowledge.py (T2:
63
+ "l5_drafts", # encrypted content; RLS on PG per 005 pattern)
64
+ })
65
+
66
+ _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
67
+ # ':org' bind β€” \b so ':org_id' / ':organization' don't satisfy the check.
68
+ _ORG_BIND_RE = re.compile(r":org\b")
69
+ _TABLE_RES = {t: re.compile(rf"\b{t}\b", re.IGNORECASE) for t in TENANT_TABLES}
70
+
71
+
72
+ class TenantScopeError(RuntimeError):
73
+ """A statement touched a tenant table without correct org scoping."""
74
+
75
+
76
+ # ── Validation ──────────────────────────────────────────────────────────────
77
+
78
+ def _require_org(org_id: str) -> str:
79
+ if not org_id or not isinstance(org_id, str):
80
+ raise TenantScopeError("org_id must be a non-empty string")
81
+ return org_id
82
+
83
+
84
+ def _tables_in(sql: str) -> set[str]:
85
+ return {t for t, rx in _TABLE_RES.items() if rx.search(sql)}
86
+
87
+
88
+ def _validate(org_id: str, sql: str, params: dict) -> None:
89
+ """Simple-parse scope check: tenant table named β†’ ':org' bind required
90
+ and params['org'] must equal the org the caller authenticated as."""
91
+ _require_org(org_id)
92
+ touched = _tables_in(sql)
93
+ if not touched:
94
+ return
95
+ if not _ORG_BIND_RE.search(sql):
96
+ raise TenantScopeError(
97
+ f"statement touches tenant table(s) {sorted(touched)} without an "
98
+ f"':org' bind β€” every tenant-table statement must scope by org "
99
+ f"(docs/TENANCY.md layer 2): {sql!r}"
100
+ )
101
+ if params.get("org") != org_id:
102
+ raise TenantScopeError(
103
+ f"params['org'] ({params.get('org')!r}) does not match the "
104
+ f"authenticated org_id ({org_id!r})"
105
+ )
106
+
107
+
108
+ # ── Scoped connection (PG: SET LOCAL app.org_id) ────────────────────────────
109
+
110
+ @contextmanager
111
+ def _scoped_conn(org_id: str):
112
+ """One transaction with the org GUC pinned (Postgres) β€” commit on exit.
113
+
114
+ set_config(..., is_local=true) == SET LOCAL: the setting dies with the
115
+ transaction, so pooled connections can never leak one org's scope into
116
+ the next request's transaction.
117
+ """
118
+ engine = db.get_engine()
119
+ with engine.begin() as conn:
120
+ if engine.dialect.name == "postgresql":
121
+ conn.execute(
122
+ text("SELECT set_config('app.org_id', :org, true)"),
123
+ {"org": org_id},
124
+ )
125
+ yield conn
126
+
127
+
128
+ # ── Public helpers ──────────────────────────────────────────────────────────
129
+
130
+ @contextmanager
131
+ def scoped_txn(org_id: str):
132
+ """Public multi-statement transaction with the org GUC pinned.
133
+
134
+ For callers that must make several reads/writes ATOMICALLY (e.g. the
135
+ evidence-chain append in atp/signing.py, which reads the chain head and
136
+ inserts under it). Yields the SQLAlchemy connection; commit on exit,
137
+ rollback on exception. Statement-level :org validation is the caller's
138
+ responsibility inside the block β€” Postgres RLS remains the backstop.
139
+ """
140
+ _require_org(org_id)
141
+ with _scoped_conn(org_id) as conn:
142
+ yield conn
143
+
144
+
145
+ def scoped_query(org_id: str, sql: str, params: dict | None = None) -> list[dict]:
146
+ """Org-scoped SELECT. Named params (:name); returns rows as list[dict]."""
147
+ params = dict(params or {})
148
+ _validate(org_id, sql, params)
149
+ with _scoped_conn(org_id) as conn:
150
+ rows = conn.execute(text(sql), params).mappings().all()
151
+ return [dict(r) for r in rows]
152
+
153
+
154
+ def scoped_execute(org_id: str, sql: str, params: dict | None = None) -> db.ExecResult:
155
+ """Org-scoped write. Same ExecResult contract as atp.db.execute()."""
156
+ params = dict(params or {})
157
+ _validate(org_id, sql, params)
158
+ with _scoped_conn(org_id) as conn:
159
+ result = conn.execute(text(sql), params)
160
+ lastrowid: int | None = None
161
+ if result.returns_rows: # INSERT ... RETURNING <id> (both dialects)
162
+ row = result.first()
163
+ if row is not None:
164
+ lastrowid = row[0]
165
+ else:
166
+ try:
167
+ lastrowid = result.lastrowid # meaningful on SQLite
168
+ except Exception: # noqa: BLE001 β€” drivers without lastrowid
169
+ lastrowid = None
170
+ return db.ExecResult(rowcount=result.rowcount, lastrowid=lastrowid)
171
+
172
+
173
+ def insert_scoped(table: str, row: dict, org_id: str) -> db.ExecResult:
174
+ """INSERT `row` into tenant `table` with org_id forced to the caller's
175
+ org (any org_id already present in `row` is overwritten β€” the verified
176
+ org always wins over payload-supplied values)."""
177
+ _require_org(org_id)
178
+ if table not in TENANT_TABLES:
179
+ raise TenantScopeError(
180
+ f"insert_scoped only handles tenant tables {sorted(TENANT_TABLES)}, "
181
+ f"got {table!r} β€” use atp.db.execute() for non-tenant tables"
182
+ )
183
+ row = dict(row)
184
+ row["org_id"] = org_id
185
+ for col in row:
186
+ if not _IDENT_RE.match(col):
187
+ raise TenantScopeError(f"invalid column name {col!r}")
188
+ cols = ", ".join(row)
189
+ marks = ", ".join(f":{c}" for c in row)
190
+ with _scoped_conn(org_id) as conn:
191
+ result = conn.execute(
192
+ text(f"INSERT INTO {table} ({cols}) VALUES ({marks})"), row)
193
+ try:
194
+ lastrowid = result.lastrowid
195
+ except Exception: # noqa: BLE001
196
+ lastrowid = None
197
+ return db.ExecResult(rowcount=result.rowcount, lastrowid=lastrowid)
198
+
199
+
200
+ def log_access(org_id: str, user: str, role: str, method: str, path: str,
201
+ table: str | None = None, row_count: int = 0,
202
+ status: int = 200) -> None:
203
+ """Append one audit row to access_log (TENANCY.md layer 4).
204
+
205
+ access_log is append-only (triggers, migration 004) and itself org-scoped
206
+ under RLS (migration 005), so the insert goes through the scoped path.
207
+ Column names follow 004's schema and avoid reserved words: the `user`
208
+ argument β†’ column `user_id`, `table` β†’ column `target_table`.
209
+ """
210
+ insert_scoped("access_log", {
211
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
212
+ "user_id": user,
213
+ "role": role,
214
+ "method": method,
215
+ "path": path,
216
+ "target_table": table,
217
+ "row_count": int(row_count),
218
+ "status": int(status),
219
+ }, org_id)