primarypath-server 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/ledger/store.py ADDED
@@ -0,0 +1,1042 @@
1
+ """The spine: append-only, hash-chained event log over Postgres.
2
+
3
+ The chain scope is (tenant_id, chain_id) — F24. Activity events
4
+ (`approval_*`, `run_event`) live on per-agent chains (chain_id = the
5
+ agent), so each agent is its own independently verifiable log; control-
6
+ plane events (`policy_set`, `admin_action`, ...) live on a per-tenant
7
+ governance chain. Every append runs in one transaction that takes a
8
+ per-chain advisory lock, reads that chain's tail, computes the chained
9
+ hash, inserts, and updates the projection. That serializes writers per
10
+ chain — agents append in parallel instead of contending on one tenant-
11
+ wide lock — and each chain's sequence stays gap-free with every
12
+ prev_hash matching the prior hash even under concurrency.
13
+
14
+ agent_id and unit are stamped INTO the payload (so the hash chain proves
15
+ them; a relabeled column would be detectable) and mirrored onto row
16
+ columns for querying.
17
+
18
+ Single-tenant, single-agent deployments use DEFAULT_TENANT/DEFAULT_AGENT
19
+ everywhere and behave exactly as before the seam existed.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import hmac
25
+ import time
26
+ from typing import Any
27
+
28
+ from psycopg import AsyncConnection
29
+ from psycopg_pool import AsyncConnectionPool
30
+
31
+ from ..faults import fault_point
32
+ from ..jcs import jcs
33
+ from .fold import fold_approvals
34
+ from .hashing import GENESIS, canonical, chain_hash
35
+ from .projection import apply_event
36
+
37
+ APPEND_LOCK = 762718294 # first advisory-lock key; second is hashtext(tenant:chain)
38
+ IDEM_LOCK = 762718295 # ... for an idempotency key; second is hashtext(tenant:key)
39
+ RUN_LOCK = 762718296 # ... for a run's ownership; second is hashtext(tenant:run_id)
40
+ DEFAULT_TENANT = "default"
41
+ DEFAULT_AGENT = "default"
42
+ GOVERNANCE_CHAIN = "__governance__"
43
+
44
+ # Control-plane event types: who changed a control, and why. They live on
45
+ # the per-tenant governance chain; no agent reasoning or run traces do.
46
+ GOVERNANCE_TYPES = frozenset({
47
+ "policy_set", "budget_set", "gate_decision", "eval_recorded",
48
+ "admin_action", "evidence_set", "agent_registered", "audit_exported",
49
+ })
50
+
51
+
52
+ def chain_for(type: str, agent_id: str | None) -> str:
53
+ return GOVERNANCE_CHAIN if type in GOVERNANCE_TYPES \
54
+ else (agent_id or DEFAULT_AGENT)
55
+
56
+
57
+ class UnknownApproval(Exception):
58
+ pass
59
+
60
+
61
+ class UnsupportedPayload(ValueError):
62
+ """Postgres cannot represent NUL (\\u0000) in jsonb; refuse it honestly
63
+ at the boundary instead of failing mid-transaction."""
64
+
65
+
66
+ def _contains_nul(v) -> bool:
67
+ if isinstance(v, str):
68
+ return "\x00" in v
69
+ if isinstance(v, dict):
70
+ return any(_contains_nul(k) or _contains_nul(x) for k, x in v.items())
71
+ if isinstance(v, (list, tuple)):
72
+ return any(_contains_nul(x) for x in v)
73
+ return False
74
+
75
+
76
+ class AlreadyDecided(Exception):
77
+ def __init__(self, approval_id: str, status: str):
78
+ super().__init__(f"{approval_id} already {status}")
79
+ self.approval_id = approval_id
80
+ self.status = status
81
+
82
+
83
+ class ActionMismatch(Exception):
84
+ """An idempotent retry of (run_id, call_id) described a DIFFERENT
85
+ action than the one the approval was created for. The old decision
86
+ must not attach to the new operation: the caller gets a conflict, not
87
+ a grant."""
88
+
89
+ def __init__(self, approval_id: str, fields: list[str]):
90
+ super().__init__(
91
+ f"{approval_id} already exists for a different action "
92
+ f"(differs in: {', '.join(fields)})")
93
+ self.approval_id = approval_id
94
+ self.fields = fields
95
+
96
+
97
+ class RunOwnedByAnotherAgent(Exception):
98
+ """A pinned credential wrote to a run_id that another agent's events
99
+ already live under. A run belongs to the agent that started it: run
100
+ reads (audit, export, trace) are authorized per run, so a shared id
101
+ would hand one agent's stream to another."""
102
+
103
+ def __init__(self, run_id: str):
104
+ super().__init__(f"run {run_id} belongs to another agent")
105
+ self.run_id = run_id
106
+
107
+
108
+ # the fields that define WHAT is being approved. Everything else on the
109
+ # request (deadline, tenant/agent binding) is context, not the action.
110
+ ACTION_FIELDS = ("tool", "arguments", "reasoning", "framework", "callback_url")
111
+
112
+
113
+ # the digest algorithm stamped next to action_digest: "jcs" (RFC 8785,
114
+ # reproducible from any language) for every new approval; absent means
115
+ # the pre-0.3 Python-canonical digest ("py"), kept only to check retries
116
+ # of approvals created before the change
117
+ DIGEST_ALG = "jcs"
118
+
119
+
120
+ def _action_record(tool, arguments, reasoning, framework, callback_url) -> dict:
121
+ return {"tool": tool, "arguments": arguments or {}, "reasoning": reasoning,
122
+ "framework": framework, "callback_url": callback_url}
123
+
124
+
125
+ def action_digest(tool: str, arguments: dict | None, reasoning: str | None,
126
+ framework: str | None, callback_url: str | None) -> str:
127
+ """sha256 over the RFC 8785 (JCS) form of the immutable action fields.
128
+ Stamped into approval_requested so the chain records exactly what was
129
+ reviewed, checked on idempotent retries, and bound into the execution
130
+ grant — where an executor in ANY language recomputes it from the
131
+ arguments it is about to dispatch (JCS is JSON.stringify over sorted
132
+ keys; app.jcs reproduces it)."""
133
+ return hashlib.sha256(jcs(_action_record(
134
+ tool, arguments, reasoning, framework, callback_url)).encode("utf-8")).hexdigest()
135
+
136
+
137
+ def action_digest_legacy(tool: str, arguments: dict | None, reasoning: str | None,
138
+ framework: str | None, callback_url: str | None) -> str:
139
+ """The pre-0.3 digest (Python canonical JSON), for retries of approvals
140
+ stamped without a digest_alg."""
141
+ return hashlib.sha256(canonical(_action_record(
142
+ tool, arguments, reasoning, framework, callback_url)).encode("utf-8")).hexdigest()
143
+
144
+
145
+ class IdempotencyMismatch(Exception):
146
+ """An Idempotency-Key reused for a different request."""
147
+
148
+
149
+ class GrantRefused(Exception):
150
+ """An execution grant that must not be redeemed: the approval is not
151
+ granted, no grant was minted, the nonce does not match, it expired,
152
+ or it was already redeemed (single use)."""
153
+
154
+
155
+ class SecondReviewerRequired(Exception):
156
+ """Dual control: the endorser cannot also give the second approval."""
157
+
158
+ def __init__(self, approval_id: str, endorsed_by: str):
159
+ super().__init__(
160
+ f"{approval_id} was endorsed by {endorsed_by}; the second "
161
+ f"approval must come from a different reviewer")
162
+ self.approval_id = approval_id
163
+ self.endorsed_by = endorsed_by
164
+
165
+
166
+ VIEW_COLUMNS = (
167
+ "approval_id, run_id, call_id, tool, arguments, reasoning, framework, "
168
+ "callback_url, status, reviewer, reason, requested_ts_ms, decided_ts_ms, "
169
+ "deadline_ms, annotations, tenant_id, agent_id, unit, endorsed_by, "
170
+ "endorsed_ts_ms, disposition, action_digest, policy, rule_result, "
171
+ "supersedes, grant_nonce, grant_issued_ms, grant_expires_ms, "
172
+ "grant_key_id, grant_signature, grant_redeemed_ts_ms, grant_redeemed_by, "
173
+ "digest_alg, grant_redeemed_instance"
174
+ )
175
+
176
+ GRANT_COLUMNS = ("grant_nonce", "grant_issued_ms", "grant_expires_ms",
177
+ "grant_key_id", "grant_signature", "grant_redeemed_ts_ms",
178
+ "grant_redeemed_by", "grant_redeemed_instance")
179
+
180
+ # every status an approval can end in; only "pending" accepts a transition
181
+ TERMINAL = ("granted", "denied", "cancelled", "expired", "superseded",
182
+ "changes_requested")
183
+
184
+ EVENT_COLUMNS = "seq, ts_ms, type, payload, prev_hash, hash, agent_id, unit"
185
+ _EVENT_KEYS = ("seq", "ts_ms", "type", "payload", "prev_hash", "hash",
186
+ "agent_id", "unit")
187
+
188
+
189
+ def _view_row_to_approval(row: tuple) -> dict:
190
+ keys = [k.strip() for k in VIEW_COLUMNS.split(",")]
191
+ a = dict(zip(keys, row))
192
+ # drop nulls for optional passthrough fields, matching the fold's shape
193
+ if a.get("framework") is None:
194
+ a.pop("framework", None)
195
+ if a.get("callback_url") is None:
196
+ a.pop("callback_url", None)
197
+ if a.get("deadline_ms") is None:
198
+ a.pop("deadline_ms", None)
199
+ if a.get("unit") is None:
200
+ a.pop("unit", None)
201
+ # dual control: only present while an endorsement is standing
202
+ if a.get("endorsed_by") is None:
203
+ a.pop("endorsed_by", None)
204
+ a.pop("endorsed_ts_ms", None)
205
+ # request-time snapshot fields: absent for pre-0020 approvals
206
+ for k in ("disposition", "action_digest", "policy", "rule_result",
207
+ "supersedes", "digest_alg"):
208
+ if a.get(k) is None:
209
+ a.pop(k, None)
210
+ # the execution grant, in the exact shape stamped into the
211
+ # approval_granted payload (the fold returns the payload's dict; the
212
+ # view reassembles it from columns, so both read the same)
213
+ if a.get("grant_nonce") is not None:
214
+ grant = {
215
+ "v": 1, "approval_id": a["approval_id"], "tenant": a["tenant_id"],
216
+ "agent_id": a["agent_id"], "tool": a["tool"],
217
+ "action_digest": a.get("action_digest") or action_digest(
218
+ a["tool"], a.get("arguments"), a.get("reasoning"),
219
+ a.get("framework"), a.get("callback_url")),
220
+ "issued_ms": a["grant_issued_ms"],
221
+ "expires_ms": a["grant_expires_ms"], "nonce": a["grant_nonce"],
222
+ "key_id": a["grant_key_id"], "signature": a["grant_signature"],
223
+ }
224
+ if a.get("grant_redeemed_ts_ms") is not None:
225
+ grant["redeemed_ts_ms"] = a["grant_redeemed_ts_ms"]
226
+ grant["redeemed_by"] = a.get("grant_redeemed_by")
227
+ if a.get("grant_redeemed_instance") is not None:
228
+ grant["redeemed_instance"] = a["grant_redeemed_instance"]
229
+ a["grant"] = grant
230
+ for k in GRANT_COLUMNS:
231
+ a.pop(k, None)
232
+ return a
233
+
234
+
235
+ def unit_predicate(units: set[str] | frozenset[str]) -> tuple[str, list]:
236
+ """SQL fragment for the need-to-know read gate: rows whose agent has a
237
+ unit are visible only when it intersects the viewer's groups; rows with
238
+ no unit assigned are visible to any authorized reader (an unassigned
239
+ agent is ungoverned by need-to-know, which keeps pre-F24 installs
240
+ working)."""
241
+ return " AND (unit IS NULL OR unit = ANY(%s))", [sorted(units)]
242
+
243
+
244
+ class Ledger:
245
+ def __init__(self, pool: AsyncConnectionPool):
246
+ self.pool = pool
247
+ self.on_append_seconds = None # optional metrics hook (F18)
248
+ # async (conn, approval, event) -> None, run INSIDE the decision's
249
+ # transaction for every granted/denied append: what a decision must
250
+ # atomically entail (the webhook outbox row) commits with it
251
+ self.decision_hooks: list = []
252
+ # (approval_view, tenant) -> grant dict, called INSIDE the granting
253
+ # transaction so the grant is stamped into approval_granted and the
254
+ # chain carries it; None = no signer, no grants (executors that
255
+ # require one refuse, fail closed)
256
+ self.grant_minter = None
257
+
258
+ # -- operation idempotency ----------------------------------------------
259
+
260
+ @staticmethod
261
+ async def _idem_begin(conn: AsyncConnection, tenant: str, idem) -> dict | None:
262
+ """Inside a write transaction: the recorded result for this key
263
+ (a replay: the caller returns it and writes nothing), or None to
264
+ proceed. A different fingerprint under the same key is refused.
265
+
266
+ Serialized on the key itself, not on its row: on FIRST use there
267
+ is no row to lock, so two simultaneous requests would both see
268
+ "no record", and the second to insert would die on the primary
269
+ key. The transaction-scoped advisory lock on (tenant, key) makes
270
+ the second wait for the first to commit, then find and replay
271
+ its result. Always taken before the chain lock (every caller
272
+ begins with this), so lock order is fixed and cannot deadlock."""
273
+ if idem is None:
274
+ return None
275
+ await conn.execute("SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
276
+ (IDEM_LOCK, f"{tenant}:{idem.key}"))
277
+ cur = await conn.execute(
278
+ "SELECT fingerprint, result FROM idempotency_keys "
279
+ "WHERE tenant_id = %s AND key = %s",
280
+ (tenant, idem.key))
281
+ row = await cur.fetchone()
282
+ if row is None:
283
+ return None
284
+ if row[0] != idem.fingerprint:
285
+ raise IdempotencyMismatch(idem.key)
286
+ return {"__replay__": True, "result": row[1]}
287
+
288
+ @staticmethod
289
+ async def _idem_commit(conn: AsyncConnection, tenant: str, idem,
290
+ result) -> None:
291
+ """Record the result in the SAME transaction as the events."""
292
+ if idem is None:
293
+ return
294
+ await conn.execute(
295
+ "INSERT INTO idempotency_keys "
296
+ "(tenant_id, key, fingerprint, result, created_ts_ms) "
297
+ "VALUES (%s, %s, %s, %s::jsonb, %s)",
298
+ (tenant, idem.key, idem.fingerprint, canonical(result),
299
+ int(time.time() * 1000)))
300
+
301
+ async def purge_idempotency_keys(self, older_than_ms: int) -> int:
302
+ async with self.pool.connection() as conn:
303
+ cur = await conn.execute(
304
+ "DELETE FROM idempotency_keys WHERE created_ts_ms < %s",
305
+ (int(time.time() * 1000) - older_than_ms,))
306
+ return cur.rowcount
307
+
308
+ # -- append core ---------------------------------------------------------
309
+
310
+ async def append(self, type: str, payload: dict,
311
+ tenant: str = DEFAULT_TENANT,
312
+ agent_id: str | None = None,
313
+ unit: str | None = None) -> dict:
314
+ start = time.perf_counter()
315
+ async with self.pool.connection() as conn:
316
+ async with conn.transaction():
317
+ event = await self._append_in_tx(conn, type, payload, tenant,
318
+ agent_id=agent_id, unit=unit)
319
+ if self.on_append_seconds is not None:
320
+ self.on_append_seconds(time.perf_counter() - start)
321
+ return event
322
+
323
+ async def _claim_run(self, conn: AsyncConnection, run_id: str,
324
+ tenant: str, agent_id: str | None,
325
+ pinned: bool, check: bool = True) -> None:
326
+ """Serialize writers of one run and refuse a pinned agent that is
327
+ not its owner. Taken BEFORE the chain append lock, always in that
328
+ order, so two agents racing to open the same run_id cannot both
329
+ win (their chain locks differ) and cannot deadlock. As with
330
+ approval ids, only a pinned credential is checked: an unpinned one
331
+ is the single-trust posture. Governance events carry no agent and
332
+ never own a run."""
333
+ await conn.execute(
334
+ "SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
335
+ (RUN_LOCK, f"{tenant}:{run_id}"))
336
+ if not (pinned and check):
337
+ return
338
+ cur = await conn.execute(
339
+ "SELECT 1 FROM events WHERE tenant_id = %s AND run_id = %s "
340
+ "AND agent_id IS NOT NULL AND agent_id <> %s LIMIT 1",
341
+ (tenant, run_id, agent_id or DEFAULT_AGENT))
342
+ if await cur.fetchone() is not None:
343
+ raise RunOwnedByAnotherAgent(run_id)
344
+
345
+ async def append_batch(self, type: str, payloads: list[dict],
346
+ tenant: str = DEFAULT_TENANT,
347
+ agent_id: str | None = None,
348
+ unit: str | None = None,
349
+ idem=None, run_id: str | None = None,
350
+ pinned: bool = False) -> list[dict]:
351
+ """Append several events of one type to one chain atomically: one
352
+ transaction holding the chain lock end to end, instead of one
353
+ transaction per event. A crash mid-batch rolls the whole batch
354
+ back, so a client retry can never leave a silently half-ingested
355
+ run."""
356
+ if not payloads and idem is None:
357
+ return []
358
+ start = time.perf_counter()
359
+ out: list[dict] = []
360
+ async with self.pool.connection() as conn:
361
+ async with conn.transaction():
362
+ replay = await self._idem_begin(conn, tenant, idem)
363
+ if replay is not None:
364
+ return replay["result"]
365
+ if run_id is not None:
366
+ await self._claim_run(conn, run_id, tenant, agent_id,
367
+ pinned)
368
+ for payload in payloads:
369
+ out.append(await self._append_in_tx(
370
+ conn, type, payload, tenant,
371
+ agent_id=agent_id, unit=unit))
372
+ await self._idem_commit(conn, tenant, idem, out)
373
+ if self.on_append_seconds is not None:
374
+ self.on_append_seconds(time.perf_counter() - start)
375
+ return out
376
+
377
+ async def _append_in_tx(self, conn: AsyncConnection, type: str,
378
+ payload: dict,
379
+ tenant: str = DEFAULT_TENANT,
380
+ agent_id: str | None = None,
381
+ unit: str | None = None) -> dict:
382
+ """Assumes it runs inside a transaction; takes the per-chain lock."""
383
+ if _contains_nul(payload):
384
+ raise UnsupportedPayload(
385
+ "payload contains NUL (\\u0000), which Postgres cannot store")
386
+ chain = chain_for(type, agent_id)
387
+ if type in GOVERNANCE_TYPES:
388
+ # governance events are BY operators, never by agents; the
389
+ # subject agent (if any) lives in the payload (e.g. evidence_set)
390
+ agent_id, unit = None, None
391
+ else:
392
+ agent_id = agent_id or DEFAULT_AGENT
393
+ # the hash chain must prove the stamped identity, so it goes in
394
+ # the payload (columns are not hashed); DEFAULT_AGENT is left
395
+ # implicit so single-agent payloads stay byte-identical to F20
396
+ if agent_id != DEFAULT_AGENT:
397
+ payload = {**payload, "agent_id": agent_id}
398
+ if unit is not None:
399
+ payload = {**payload, "unit": unit}
400
+ await conn.execute("SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
401
+ (APPEND_LOCK, f"{tenant}:{chain}"))
402
+ cur = await conn.execute(
403
+ "SELECT seq, hash FROM events WHERE tenant_id = %s "
404
+ "AND chain_id = %s ORDER BY seq DESC LIMIT 1", (tenant, chain))
405
+ row = await cur.fetchone()
406
+ seq = (row[0] + 1) if row else 1
407
+ prev = row[1] if row else GENESIS
408
+ ts_ms = int(time.time() * 1000)
409
+ payload_json = canonical(payload)
410
+ h = chain_hash(seq, ts_ms, type, payload_json, prev)
411
+ # stored as the exact canonical TEXT that was hashed (byte-identical
412
+ # on export; payload_json is a derived column for querying)
413
+ await conn.execute(
414
+ "INSERT INTO events (tenant_id, chain_id, seq, ts_ms, type, "
415
+ "payload, prev_hash, hash, run_id, agent_id, unit) "
416
+ "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
417
+ (tenant, chain, seq, ts_ms, type, payload_json, prev, h,
418
+ payload.get("run_id"), agent_id, unit),
419
+ )
420
+ fault_point("after_event_insert_before_commit")
421
+ fault_point("before_projection_update")
422
+ await apply_event(conn, type, payload, ts_ms, tenant,
423
+ agent_id=agent_id, unit=unit, event_hash=h)
424
+ return {"seq": seq, "ts_ms": ts_ms, "type": type,
425
+ "payload": payload, "prev_hash": prev, "hash": h,
426
+ "tenant_id": tenant, "chain_id": chain}
427
+
428
+ # -- queries -------------------------------------------------------------
429
+
430
+ async def events(self, tenant: str = DEFAULT_TENANT,
431
+ chain: str | None = None) -> list[dict]:
432
+ """Events with payload as the exact stored canonical TEXT (the bytes
433
+ that were hashed). With `chain`, one chain in seq order (a verifiable
434
+ unit); without, the tenant's whole log in ingest order (a read for
435
+ listings and type filters, NOT a single verifiable chain)."""
436
+ async with self.pool.connection() as conn:
437
+ if chain is not None:
438
+ cur = await conn.execute(
439
+ f"SELECT {EVENT_COLUMNS} FROM events WHERE tenant_id = %s "
440
+ f"AND chain_id = %s ORDER BY seq", (tenant, chain))
441
+ else:
442
+ cur = await conn.execute(
443
+ f"SELECT {EVENT_COLUMNS} FROM events WHERE tenant_id = %s "
444
+ f"ORDER BY rowid", (tenant,))
445
+ rows = await cur.fetchall()
446
+ return [self._event_row(r) for r in rows]
447
+
448
+ @staticmethod
449
+ def _event_row(r: tuple) -> dict:
450
+ e = dict(zip(_EVENT_KEYS, r))
451
+ # the default agent stays implicit (single-agent installs keep the
452
+ # F20 event shape); governance events carry no agent at all
453
+ if e.get("agent_id") in (None, DEFAULT_AGENT):
454
+ e.pop("agent_id", None)
455
+ if e.get("unit") is None:
456
+ e.pop("unit", None)
457
+ return e
458
+
459
+ async def chains(self, tenant: str = DEFAULT_TENANT) -> list[dict]:
460
+ """Every chain in the tenant with its length and head."""
461
+ async with self.pool.connection() as conn:
462
+ cur = await conn.execute(
463
+ "SELECT DISTINCT ON (chain_id) chain_id, seq, hash "
464
+ "FROM events WHERE tenant_id = %s "
465
+ "ORDER BY chain_id, seq DESC", (tenant,))
466
+ rows = await cur.fetchall()
467
+ return [{"chain_id": r[0], "length": r[1], "head": r[2]}
468
+ for r in rows]
469
+
470
+ async def chain_has_unit_outside(self, tenant: str, chain: str,
471
+ units: set[str]) -> bool:
472
+ """Whether any event on `chain` is stamped with a unit not in
473
+ `units`. An agent moved between units carries both on its chain;
474
+ only a reader of every one of them may take the whole chain (B23).
475
+ A range scan on the (tenant_id, chain_id, seq) key."""
476
+ async with self.pool.connection() as conn:
477
+ cur = await conn.execute(
478
+ "SELECT EXISTS (SELECT 1 FROM events WHERE tenant_id = %s "
479
+ "AND chain_id = %s AND unit IS NOT NULL "
480
+ "AND NOT (unit = ANY(%s)))", (tenant, chain, sorted(units)))
481
+ row = await cur.fetchone()
482
+ return bool(row and row[0])
483
+
484
+ async def events_for_run(self, run_id: str,
485
+ tenant: str = DEFAULT_TENANT) -> list[dict]:
486
+ """A run's events all live on its agent's chain, so seq order is
487
+ the chain order."""
488
+ async with self.pool.connection() as conn:
489
+ cur = await conn.execute(
490
+ f"SELECT {EVENT_COLUMNS} FROM events WHERE tenant_id = %s "
491
+ f"AND run_id = %s ORDER BY seq", (tenant, run_id))
492
+ rows = await cur.fetchall()
493
+ return [self._event_row(r) for r in rows]
494
+
495
+ async def head(self, tenant: str = DEFAULT_TENANT,
496
+ chain: str = DEFAULT_AGENT) -> str:
497
+ async with self.pool.connection() as conn:
498
+ cur = await conn.execute(
499
+ "SELECT hash FROM events WHERE tenant_id = %s "
500
+ "AND chain_id = %s ORDER BY seq DESC LIMIT 1",
501
+ (tenant, chain))
502
+ row = await cur.fetchone()
503
+ return row[0] if row else GENESIS
504
+
505
+ async def export_bundle(self, tenant: str = DEFAULT_TENANT,
506
+ chain: str = DEFAULT_AGENT) -> dict:
507
+ """One chain as a self-contained, independently verifiable bundle.
508
+ No re-serialization: the payload text is byte-identical to what was
509
+ hashed at append time. The default-agent bundle of a single-agent
510
+ install is the same surface as the F20 tenant bundle."""
511
+ events = await self.events(tenant, chain=chain)
512
+ head = events[-1]["hash"] if events else GENESIS
513
+ bundle = {"events": events, "head": head}
514
+ if tenant != DEFAULT_TENANT:
515
+ bundle["tenant_id"] = tenant
516
+ if chain != DEFAULT_AGENT:
517
+ bundle["chain_id"] = chain
518
+ return bundle
519
+
520
+ async def export_full(self, tenant: str = DEFAULT_TENANT) -> dict:
521
+ """The whole log: every chain, each its own verifiable unit. There
522
+ is no single cross-chain hash — per-chain heads are the truth."""
523
+ chains = []
524
+ for c in sorted(ch["chain_id"] for ch in await self.chains(tenant)):
525
+ b = await self.export_bundle(tenant, chain=c)
526
+ chains.append({"chain_id": c, "events": b["events"],
527
+ "head": b["head"]})
528
+ bundle = {"scope": "full", "chains": chains}
529
+ if tenant != DEFAULT_TENANT:
530
+ bundle["tenant_id"] = tenant
531
+ return bundle
532
+
533
+ # -- approvals -----------------------------------------------------------
534
+
535
+ async def create_approval(self, run_id: str, call_id: str, tool: str,
536
+ arguments: dict | None = None,
537
+ reasoning: str | None = None,
538
+ framework: str | None = None,
539
+ deadline_ms: int | None = None,
540
+ callback_url: str | None = None,
541
+ tenant: str = DEFAULT_TENANT,
542
+ agent_id: str = DEFAULT_AGENT,
543
+ unit: str | None = None,
544
+ disposition: str | None = None,
545
+ policy: dict | None = None,
546
+ rule_result: list | None = None,
547
+ supersedes: str | None = None,
548
+ pinned: bool = False) -> tuple[dict, bool]:
549
+ """Idempotent on (tenant, run_id, call_id). Returns (approval, created).
550
+ agent_id/unit come from the service token (resolved server-side),
551
+ never from the request body — an agent cannot claim another unit.
552
+ `pinned` says the credential is bound to agent_id (an issued token
553
+ with an agent); it is credential metadata, never inferred from the
554
+ agent's name, so an agent literally called "default" is pinned too.
555
+ `disposition` is the create-time evaluation ("auto"/"deny"/"gated"),
556
+ stamped into the chain so an idempotent retry executes the original
557
+ intent instead of re-evaluating live policy."""
558
+ approval_id = f"{run_id}:{call_id}"
559
+ async with self.pool.connection() as conn:
560
+ async with conn.transaction():
561
+ # run lock first, chain lock second (see _claim_run); the
562
+ # ownership check itself waits until the id is known to be
563
+ # new, so a reused approval id keeps its ActionMismatch
564
+ await self._claim_run(conn, run_id, tenant, agent_id,
565
+ pinned, check=False)
566
+ await conn.execute(
567
+ "SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
568
+ (APPEND_LOCK, f"{tenant}:{agent_id}"))
569
+ digest = action_digest(tool, arguments, reasoning,
570
+ framework, callback_url)
571
+ existing = await self._get_view(conn, approval_id, tenant)
572
+ if existing is not None:
573
+ # idempotent only for the SAME agent: a pinned agent
574
+ # that reuses another agent's run_id:call_id must not
575
+ # be handed that agent's approval (ownership is part
576
+ # of the identity; an unpinned credential stays
577
+ # single-trust, as for cancel and evidence writes)
578
+ if pinned and existing.get("agent_id") != agent_id:
579
+ raise ActionMismatch(approval_id, ["agent"])
580
+ # ...and only for the SAME action: an id reused for a
581
+ # different tool/arguments/callback must never inherit
582
+ # the earlier decision
583
+ await self._check_same_action(
584
+ conn, existing, run_id, tenant, digest,
585
+ tool=tool, arguments=arguments or {},
586
+ reasoning=reasoning, framework=framework,
587
+ callback_url=callback_url)
588
+ return existing, False
589
+ # approval ingestion is a run write like any other
590
+ await self._claim_run(conn, run_id, tenant, agent_id, pinned)
591
+ payload: dict[str, Any] = {
592
+ "approval_id": approval_id, "run_id": run_id,
593
+ "call_id": call_id, "tool": tool,
594
+ "arguments": arguments or {}, "reasoning": reasoning,
595
+ "action_digest": digest, "digest_alg": DIGEST_ALG,
596
+ }
597
+ if framework is not None:
598
+ payload["framework"] = framework
599
+ if deadline_ms is not None:
600
+ payload["deadline_ms"] = deadline_ms
601
+ if callback_url is not None:
602
+ payload["callback_url"] = callback_url
603
+ if disposition is not None:
604
+ payload["disposition"] = disposition
605
+ if policy is not None:
606
+ # the rule as it stood, with its version: what this
607
+ # request was evaluated against, in the chain
608
+ payload["policy"] = policy
609
+ if rule_result is not None:
610
+ payload["rule_result"] = rule_result
611
+ if supersedes is not None:
612
+ # the older proposal this one replaces (arguments or
613
+ # context changed): it closes in the SAME transaction,
614
+ # so there is never a moment with two live proposals
615
+ old = await self._get_view(conn, supersedes, tenant)
616
+ if old is None:
617
+ raise UnknownApproval(supersedes)
618
+ if old["status"] not in ("pending", "changes_requested"):
619
+ raise AlreadyDecided(supersedes, old["status"])
620
+ if old.get("agent_id") != agent_id:
621
+ raise ActionMismatch(supersedes, ["agent"])
622
+ if old["status"] == "pending":
623
+ await self._append_in_tx(conn, "approval_superseded", {
624
+ "approval_id": supersedes,
625
+ "run_id": old["run_id"],
626
+ "superseded_by": approval_id,
627
+ "reviewer": f"agent:{agent_id}",
628
+ "reason": f"superseded by {approval_id}",
629
+ }, tenant, agent_id=agent_id, unit=unit)
630
+ closed = await self._get_view(conn, supersedes, tenant)
631
+ assert closed is not None
632
+ await self._run_hooks(
633
+ conn, closed, {"hash": None, "tenant_id": tenant})
634
+ else:
635
+ # the REVISION a reviewer asked for. The predecessor
636
+ # is already closed, and its outcome is evidence (who
637
+ # sent it back, and why): nothing is appended to it.
638
+ # The link lives on the new proposal, which is how
639
+ # the case shows the reviewer what changed. One live
640
+ # revision at a time, serialized by the chain lock
641
+ # (same agent): a second one revises the first.
642
+ cur = await conn.execute(
643
+ "SELECT approval_id FROM approvals_view "
644
+ "WHERE tenant_id = %s AND supersedes = %s "
645
+ "AND status = 'pending' LIMIT 1",
646
+ (tenant, supersedes))
647
+ live = await cur.fetchone()
648
+ if live is not None:
649
+ raise AlreadyDecided(
650
+ supersedes, f"being revised by {live[0]}")
651
+ payload["supersedes"] = supersedes
652
+ await self._append_in_tx(conn, "approval_requested", payload,
653
+ tenant, agent_id=agent_id, unit=unit)
654
+ created = await self._get_view(conn, approval_id, tenant)
655
+ assert created is not None # just appended in this transaction
656
+ return created, True
657
+
658
+ async def _check_same_action(self, conn: AsyncConnection, existing: dict,
659
+ run_id: str, tenant: str, digest: str,
660
+ **fields) -> None:
661
+ """Raise ActionMismatch unless the retry describes the action the
662
+ approval was created for. Compares against the digest stamped in
663
+ the chain, recomputed with the algorithm stamped next to it (a
664
+ pre-0.3 approval carries a Python-canonical digest and no
665
+ digest_alg); events from before any stamp existed fall back to a
666
+ field-by-field comparison against the folded view."""
667
+ stamped = await self._requested_payload(
668
+ conn, existing["approval_id"], run_id, tenant)
669
+ stamped_digest = (stamped or {}).get("action_digest")
670
+ if stamped_digest is not None:
671
+ if (stamped or {}).get("digest_alg") != DIGEST_ALG:
672
+ digest = action_digest_legacy(
673
+ str(fields.get("tool")), fields.get("arguments"),
674
+ fields.get("reasoning"), fields.get("framework"),
675
+ fields.get("callback_url"))
676
+ if stamped_digest == digest:
677
+ return
678
+ differing = [f for f in ACTION_FIELDS
679
+ if (existing.get(f) or ({} if f == "arguments" else None))
680
+ != (fields.get(f) or ({} if f == "arguments" else None))]
681
+ if stamped_digest is None and not differing:
682
+ return # pre-digest event, same action
683
+ raise ActionMismatch(existing["approval_id"],
684
+ differing or ["action_digest"])
685
+
686
+ @staticmethod
687
+ async def _requested_payload(conn: AsyncConnection, approval_id: str,
688
+ run_id: str, tenant: str) -> dict | None:
689
+ cur = await conn.execute(
690
+ "SELECT payload_json FROM events "
691
+ "WHERE tenant_id = %s AND run_id = %s "
692
+ "AND type = 'approval_requested' "
693
+ "AND payload_json->>'approval_id' = %s LIMIT 1",
694
+ (tenant, run_id, approval_id))
695
+ row = await cur.fetchone()
696
+ return row[0] if row else None
697
+
698
+ async def requested_disposition(self, approval_id: str, run_id: str,
699
+ tenant: str = DEFAULT_TENANT) -> str | None:
700
+ """The create-time evaluation stamped in the approval_requested
701
+ event. None for events written before the stamp existed — callers
702
+ treat that as gated (fail safe: a human finishes what a crash left
703
+ pending)."""
704
+ async with self.pool.connection() as conn:
705
+ cur = await conn.execute(
706
+ "SELECT payload_json->>'disposition' FROM events "
707
+ "WHERE tenant_id = %s AND run_id = %s "
708
+ "AND type = 'approval_requested' "
709
+ "AND payload_json->>'approval_id' = %s LIMIT 1",
710
+ (tenant, run_id, approval_id))
711
+ row = await cur.fetchone()
712
+ return row[0] if row else None
713
+
714
+ async def decide(self, approval_id: str, approved: bool,
715
+ reviewer: str, reason: str = "",
716
+ reviewer_role: str | None = None,
717
+ require_second: bool = False,
718
+ tenant: str = DEFAULT_TENANT, idem=None) -> dict:
719
+ """Write a decision. With `require_second` (dual control, from the
720
+ action's policy), the FIRST approval appends `approval_endorsed`
721
+ and the approval stays pending; only a DIFFERENT reviewer's
722
+ approval appends `approval_granted`. A denial always decides
723
+ immediately. All checks run inside the chain-locked transaction,
724
+ so two racing approvers cannot both count as the pair."""
725
+ async with self.pool.connection() as conn:
726
+ async with conn.transaction():
727
+ replay = await self._idem_begin(conn, tenant, idem)
728
+ if replay is not None:
729
+ return replay["result"]
730
+ a = await self._lock_approval_chain(conn, approval_id, tenant)
731
+ if a is None:
732
+ raise UnknownApproval(approval_id)
733
+ if a["status"] != "pending":
734
+ raise AlreadyDecided(approval_id, a["status"])
735
+ if approved and require_second:
736
+ endorsed_by = a.get("endorsed_by")
737
+ if endorsed_by is None:
738
+ payload = {"approval_id": approval_id,
739
+ "run_id": a["run_id"],
740
+ "reviewer": reviewer, "reason": reason}
741
+ if reviewer_role is not None:
742
+ payload["reviewer_role"] = reviewer_role
743
+ await self._append_in_tx(
744
+ conn, "approval_endorsed", payload, tenant,
745
+ agent_id=a["agent_id"], unit=a.get("unit"))
746
+ endorsed = await self._get_view(conn, approval_id, tenant)
747
+ assert endorsed is not None
748
+ await self._idem_commit(conn, tenant, idem, endorsed)
749
+ return endorsed
750
+ if endorsed_by == reviewer:
751
+ raise SecondReviewerRequired(approval_id, endorsed_by)
752
+ etype = "approval_granted" if approved else "approval_denied"
753
+ payload = {"approval_id": approval_id, "run_id": a["run_id"],
754
+ "reviewer": reviewer, "reason": reason}
755
+ if reviewer_role is not None:
756
+ payload["reviewer_role"] = reviewer_role
757
+ if approved and self.grant_minter is not None:
758
+ # the execution grant is born with the decision: same
759
+ # transaction, same event, same chain
760
+ payload["grant"] = self.grant_minter(a, tenant)
761
+ event = await self._append_in_tx(conn, etype, payload, tenant,
762
+ agent_id=a["agent_id"],
763
+ unit=a.get("unit"))
764
+ view = await self._get_view(conn, approval_id, tenant)
765
+ assert view is not None # written in this transaction
766
+ await self._run_hooks(conn, view, event)
767
+ await self._idem_commit(conn, tenant, idem, view)
768
+ return view
769
+
770
+ async def redeem_grant(self, approval_id: str, nonce: str, *,
771
+ executor: str, instance: str | None = None,
772
+ tenant: str = DEFAULT_TENANT,
773
+ only_agent: str | None = None,
774
+ now_ms: int | None = None,
775
+ idem=None) -> tuple[dict, dict]:
776
+ """Redeem an execution grant exactly once, under the chain lock:
777
+ the approval must be granted, carry a grant, the nonce must match
778
+ (constant-time), the grant must be unexpired and unredeemed. The
779
+ `grant_redeemed` event is the chain's record that the executor
780
+ ran the approved action — and that it could run it only once.
781
+ `executor` is the credential-derived identity (server-authored,
782
+ the attribution); `instance` is the executor's own name for
783
+ itself (self-asserted, informational), stored separately.
784
+ `only_agent` is the credential's pin (None = unpinned), never
785
+ inferred from an agent's name."""
786
+ async with self.pool.connection() as conn:
787
+ async with conn.transaction():
788
+ replay = await self._idem_begin(conn, tenant, idem)
789
+ if replay is not None:
790
+ # the same executor asking again for the same grant: it
791
+ # never got its answer; the grant was spent by ITS call,
792
+ # so it may dispatch
793
+ return replay["result"]["view"], replay["result"]["event"]
794
+ a = await self._lock_approval_chain(conn, approval_id, tenant)
795
+ if a is None:
796
+ raise UnknownApproval(approval_id)
797
+ if only_agent is not None and a.get("agent_id") != only_agent:
798
+ raise ActionMismatch(approval_id, ["agent"])
799
+ if a["status"] != "granted":
800
+ raise GrantRefused(f"{approval_id} is {a['status']}, "
801
+ f"not granted")
802
+ grant = a.get("grant")
803
+ if grant is None:
804
+ raise GrantRefused(
805
+ f"{approval_id} carries no execution grant (no "
806
+ f"signing key was configured when it was granted)")
807
+ if not hmac.compare_digest(str(nonce), grant["nonce"]):
808
+ raise GrantRefused("nonce does not match the grant")
809
+ if grant.get("redeemed_ts_ms") is not None:
810
+ raise GrantRefused(
811
+ f"grant already redeemed by {grant.get('redeemed_by')}"
812
+ f" at {grant['redeemed_ts_ms']} (single use)")
813
+ now = int(time.time() * 1000) if now_ms is None else now_ms
814
+ if now > int(grant["expires_ms"]):
815
+ raise GrantRefused(f"grant expired at "
816
+ f"{grant['expires_ms']}")
817
+ payload = {
818
+ "approval_id": approval_id, "run_id": a["run_id"],
819
+ "nonce": grant["nonce"], "tool": a["tool"],
820
+ "action_digest": grant["action_digest"],
821
+ "executor": executor,
822
+ }
823
+ if instance:
824
+ payload["executor_instance"] = instance
825
+ event = await self._append_in_tx(
826
+ conn, "grant_redeemed", payload, tenant,
827
+ agent_id=a["agent_id"], unit=a.get("unit"))
828
+ redeemed = await self._get_view(conn, approval_id, tenant)
829
+ assert redeemed is not None
830
+ await self._idem_commit(conn, tenant, idem,
831
+ {"view": redeemed, "event": event})
832
+ return redeemed, event
833
+
834
+ async def _run_hooks(self, conn, view: dict, event: dict) -> None:
835
+ """Every terminal transition tells the agent (webhook outbox row,
836
+ in this transaction) — a cancellation or expiry is as final for
837
+ the waiting agent as a denial."""
838
+ for hook in self.decision_hooks:
839
+ await hook(conn, view, event)
840
+
841
+ async def _transition(self, approval_id: str, etype: str, *,
842
+ reviewer: str, reason: str,
843
+ extra: dict | None = None,
844
+ tenant: str = DEFAULT_TENANT,
845
+ only_agent: str | None = None,
846
+ idem=None) -> dict:
847
+ """A non-decision terminal transition (cancel / expire /
848
+ changes-requested): pending -> the lifecycle status, first one
849
+ wins, under the chain lock, hooks run in the transaction."""
850
+ async with self.pool.connection() as conn:
851
+ async with conn.transaction():
852
+ replay = await self._idem_begin(conn, tenant, idem)
853
+ if replay is not None:
854
+ return replay["result"]
855
+ a = await self._lock_approval_chain(conn, approval_id, tenant)
856
+ if a is None:
857
+ raise UnknownApproval(approval_id)
858
+ if a["status"] != "pending":
859
+ raise AlreadyDecided(approval_id, a["status"])
860
+ if only_agent is not None and a.get("agent_id") != only_agent:
861
+ raise ActionMismatch(approval_id, ["agent"])
862
+ payload = {"approval_id": approval_id, "run_id": a["run_id"],
863
+ "reviewer": reviewer, "reason": reason,
864
+ **(extra or {})}
865
+ event = await self._append_in_tx(
866
+ conn, etype, payload, tenant, agent_id=a["agent_id"],
867
+ unit=a.get("unit"))
868
+ view = await self._get_view(conn, approval_id, tenant)
869
+ assert view is not None # written in this transaction
870
+ await self._run_hooks(conn, view, event)
871
+ await self._idem_commit(conn, tenant, idem, view)
872
+ return view
873
+
874
+ async def cancel(self, approval_id: str, agent_id: str, reason: str = "",
875
+ tenant: str = DEFAULT_TENANT, pinned: bool = False,
876
+ idem=None) -> dict:
877
+ """The proposing agent withdraws its own pending proposal (a pinned
878
+ credential can only cancel its agent's; an unpinned one any).
879
+ `pinned` is credential metadata, never the agent's name."""
880
+ return await self._transition(
881
+ approval_id, "approval_cancelled", reviewer=f"agent:{agent_id}",
882
+ reason=reason or "withdrawn by the agent", tenant=tenant,
883
+ only_agent=agent_id if pinned else None, idem=idem)
884
+
885
+ async def expire(self, approval_id: str, reason: str,
886
+ tenant: str = DEFAULT_TENANT) -> dict:
887
+ """The deadline passed with no decision: the proposal expires. The
888
+ tool never runs; a fresh proposal is needed."""
889
+ return await self._transition(
890
+ approval_id, "approval_expired", reviewer="system:sla",
891
+ reason=reason, tenant=tenant)
892
+
893
+ async def request_changes(self, approval_id: str, reviewer: str,
894
+ reason: str, reviewer_role: str | None = None,
895
+ tenant: str = DEFAULT_TENANT, idem=None) -> dict:
896
+ """A human sends the proposal back: not approved, not refused —
897
+ change it and propose again (the new one `supersedes` this)."""
898
+ extra = {"reviewer_role": reviewer_role} if reviewer_role else None
899
+ return await self._transition(
900
+ approval_id, "approval_changes_requested", reviewer=reviewer,
901
+ reason=reason, extra=extra, tenant=tenant, idem=idem)
902
+
903
+ async def annotate(self, approval_id: str, reviewer: str, note: str,
904
+ tenant: str = DEFAULT_TENANT, idem=None) -> dict:
905
+ async with self.pool.connection() as conn:
906
+ async with conn.transaction():
907
+ replay = await self._idem_begin(conn, tenant, idem)
908
+ if replay is not None:
909
+ return replay["result"]
910
+ a = await self._lock_approval_chain(conn, approval_id, tenant)
911
+ if a is None:
912
+ raise UnknownApproval(approval_id)
913
+ await self._append_in_tx(conn, "approval_annotated", {
914
+ "approval_id": approval_id, "run_id": a["run_id"],
915
+ "reviewer": reviewer, "note": note}, tenant,
916
+ agent_id=a["agent_id"], unit=a.get("unit"))
917
+ annotated = await self._get_view(conn, approval_id, tenant)
918
+ assert annotated is not None
919
+ await self._idem_commit(conn, tenant, idem, annotated)
920
+ return annotated
921
+
922
+ async def report_incident(self, approval_id: str, reviewer: str,
923
+ severity: str, summary: str,
924
+ tenant: str = DEFAULT_TENANT, idem=None) -> dict:
925
+ """A structured outcome event on the approval's own chain: this
926
+ action, once executed, caused (or contributed to) an incident. The
927
+ rollout track record counts these — never a word in a note."""
928
+ async with self.pool.connection() as conn:
929
+ async with conn.transaction():
930
+ replay = await self._idem_begin(conn, tenant, idem)
931
+ if replay is not None:
932
+ return replay["result"]
933
+ a = await self._lock_approval_chain(conn, approval_id, tenant)
934
+ if a is None:
935
+ raise UnknownApproval(approval_id)
936
+ await self._append_in_tx(conn, "incident_reported", {
937
+ "approval_id": approval_id, "run_id": a["run_id"],
938
+ "tool": a["tool"], "reviewer": reviewer,
939
+ "severity": severity, "summary": summary,
940
+ }, tenant, agent_id=a["agent_id"], unit=a.get("unit"))
941
+ reported = await self._get_view(conn, approval_id, tenant)
942
+ assert reported is not None
943
+ await self._idem_commit(conn, tenant, idem, reported)
944
+ return reported
945
+
946
+ async def _lock_approval_chain(self, conn: AsyncConnection,
947
+ approval_id: str,
948
+ tenant: str) -> dict | None:
949
+ """Look up the approval, then serialize on ITS agent's chain: a
950
+ decision event must land on the chain that carries the request."""
951
+ a = await self._get_view(conn, approval_id, tenant)
952
+ if a is None:
953
+ return None
954
+ await conn.execute(
955
+ "SELECT pg_advisory_xact_lock(%s, hashtext(%s))",
956
+ (APPEND_LOCK, f"{tenant}:{a['agent_id']}"))
957
+ # re-read under the lock: a racing decision may have landed
958
+ return await self._get_view(conn, approval_id, tenant)
959
+
960
+ async def approval(self, approval_id: str,
961
+ tenant: str = DEFAULT_TENANT) -> dict | None:
962
+ async with self.pool.connection() as conn:
963
+ return await self._get_view(conn, approval_id, tenant)
964
+
965
+ async def approvals(self, status: str | None = None,
966
+ limit: int = 100, offset: int = 0,
967
+ tenant: str = DEFAULT_TENANT,
968
+ units: set[str] | None = None,
969
+ agent: str | None = None,
970
+ newest_first: bool = False,
971
+ after: tuple[int, str] | None = None) -> list[dict]:
972
+ """`units` is the need-to-know gate: None = unrestricted (dev/open,
973
+ or a viewer the gate does not apply to); a set = only agents whose
974
+ unit intersects it (plus unit-less agents). `agent` pins one agent
975
+ (a service token reading its own queue).
976
+
977
+ `after` is a keyset cursor, the (requested_ts_ms, approval_id) of
978
+ the last row already held: unlike an offset it does not skip or
979
+ repeat rows when the set changes between pages (a pending page
980
+ shrinks every time someone decides)."""
981
+ q = f"SELECT {VIEW_COLUMNS} FROM approvals_view WHERE tenant_id = %s"
982
+ params: list = [tenant]
983
+ if status:
984
+ q += " AND status = %s"
985
+ params.append(status)
986
+ if units is not None:
987
+ frag, p = unit_predicate(units)
988
+ q += frag
989
+ params += p
990
+ if agent is not None:
991
+ q += " AND agent_id = %s"
992
+ params.append(agent)
993
+ if after is not None:
994
+ q += (" AND (requested_ts_ms, approval_id) "
995
+ + ("<" if newest_first else ">") + " (%s, %s)")
996
+ params += [after[0], after[1]]
997
+ # approval_id tiebreak: batch-created rows share a requested_ts_ms,
998
+ # and without a total order pages duplicate or skip rows
999
+ q += (" ORDER BY requested_ts_ms DESC, approval_id DESC"
1000
+ if newest_first else " ORDER BY requested_ts_ms, approval_id")
1001
+ q += " LIMIT %s OFFSET %s"
1002
+ params += [limit, offset]
1003
+ async with self.pool.connection() as conn:
1004
+ cur = await conn.execute(q, params)
1005
+ rows = await cur.fetchall()
1006
+ return [_view_row_to_approval(r) for r in rows]
1007
+
1008
+ async def pending_count(self, tenant: str = DEFAULT_TENANT,
1009
+ units: set[str] | None = None,
1010
+ agent: str | None = None) -> int:
1011
+ """The nav badge — unit-scoped so it never leaks another unit's
1012
+ volume."""
1013
+ q = ("SELECT count(*) FROM approvals_view WHERE tenant_id = %s "
1014
+ "AND status = 'pending'")
1015
+ params: list = [tenant]
1016
+ if units is not None:
1017
+ frag, p = unit_predicate(units)
1018
+ q += frag
1019
+ params += p
1020
+ if agent is not None:
1021
+ q += " AND agent_id = %s"
1022
+ params.append(agent)
1023
+ async with self.pool.connection() as conn:
1024
+ cur = await conn.execute(q, params)
1025
+ row = await cur.fetchone()
1026
+ return row[0] if row else 0
1027
+
1028
+ async def approval_folded(self, approval_id: str,
1029
+ tenant: str = DEFAULT_TENANT) -> dict | None:
1030
+ """The same state derived straight from the log (truth, not cache)."""
1031
+ run_id = approval_id.rsplit(":", 1)[0]
1032
+ events = await self.events_for_run(run_id, tenant)
1033
+ return fold_approvals(events).get(approval_id)
1034
+
1035
+ async def _get_view(self, conn: AsyncConnection, approval_id: str,
1036
+ tenant: str = DEFAULT_TENANT) -> dict | None:
1037
+ cur = await conn.execute(
1038
+ f"SELECT {VIEW_COLUMNS} FROM approvals_view "
1039
+ f"WHERE tenant_id = %s AND approval_id = %s",
1040
+ (tenant, approval_id))
1041
+ row = await cur.fetchone()
1042
+ return _view_row_to_approval(row) if row else None