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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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
|