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/jcs.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""RFC 8785 JSON Canonicalization Scheme (JCS): the cross-language canonical
|
|
2
|
+
form the action digest is computed over (digest_alg "jcs").
|
|
3
|
+
|
|
4
|
+
Why not json.dumps(sort_keys=True, separators=(",", ":")): its number
|
|
5
|
+
formatting is Python's (1.0 -> "1.0", 1e16 -> "1e+16"), which no other
|
|
6
|
+
language reproduces without reimplementing Python. JCS pins numbers to
|
|
7
|
+
ECMAScript's Number.prototype.toString (1.0 -> "1",
|
|
8
|
+
1e16 -> "10000000000000000", 1e21 -> "1e+21"), sorts object keys by
|
|
9
|
+
UTF-16 code units, and escapes strings exactly as JSON.stringify does,
|
|
10
|
+
so `JSON.stringify` over a key-sorted object IS the reference
|
|
11
|
+
implementation and this module reproduces it byte for byte.
|
|
12
|
+
|
|
13
|
+
The chain hash (`ledger.hashing.canonical`) is NOT this: that format is
|
|
14
|
+
frozen. JCS is only for values two independent parties must digest
|
|
15
|
+
identically, which is the action an executor is about to run.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
|
|
21
|
+
# ECMAScript Number::toString thresholds
|
|
22
|
+
_EXP_UPPER = 21 # >= 1e21 switches to exponent form
|
|
23
|
+
_EXP_LOWER = -6 # < 1e-6 switches to exponent form
|
|
24
|
+
_MAX_SAFE = 2 ** 53
|
|
25
|
+
_SHORT = {8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotCanonicalizable(ValueError):
|
|
29
|
+
"""A value JSON (and therefore JCS) cannot represent."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _es_number(x: float) -> str:
|
|
33
|
+
"""Number::toString for a finite double: the shortest round-trip digit
|
|
34
|
+
string (Python's repr yields the same digits as ECMAScript's
|
|
35
|
+
"shortest" rule) laid out with ECMAScript's rules."""
|
|
36
|
+
if x == 0:
|
|
37
|
+
return "0" # -0 prints as "0"
|
|
38
|
+
sign = "-" if x < 0 else ""
|
|
39
|
+
r = repr(abs(x)) # '1.5', '1e-07', '1.2345e+21', '123.0'
|
|
40
|
+
mantissa, _, exp = r.partition("e")
|
|
41
|
+
e = int(exp) if exp else 0
|
|
42
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
43
|
+
int_digits = int_part.lstrip("0")
|
|
44
|
+
if int_digits:
|
|
45
|
+
n = len(int_digits) + e
|
|
46
|
+
else:
|
|
47
|
+
n = e - (len(frac_part) - len(frac_part.lstrip("0")))
|
|
48
|
+
digits = (int_digits + frac_part if int_digits else frac_part.lstrip("0"))
|
|
49
|
+
digits = digits.rstrip("0")
|
|
50
|
+
k = len(digits)
|
|
51
|
+
if k == 0:
|
|
52
|
+
return "0"
|
|
53
|
+
if k <= n <= _EXP_UPPER:
|
|
54
|
+
return sign + digits + "0" * (n - k)
|
|
55
|
+
if 0 < n <= _EXP_UPPER:
|
|
56
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
57
|
+
if _EXP_LOWER < n <= 0:
|
|
58
|
+
return sign + "0." + "0" * (-n) + digits
|
|
59
|
+
exp10 = n - 1
|
|
60
|
+
exp_s = ("+" if exp10 >= 0 else "-") + str(abs(exp10))
|
|
61
|
+
if k == 1:
|
|
62
|
+
return sign + digits + "e" + exp_s
|
|
63
|
+
return sign + digits[0] + "." + digits[1:] + "e" + exp_s
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _string(s: str) -> str:
|
|
67
|
+
"""JSON.stringify's string form: only `"`, backslash and control
|
|
68
|
+
characters are escaped (short forms for \\b \\f \\n \\r \\t, else
|
|
69
|
+
\\u00xx lowercase); everything else, non-ASCII included, is emitted
|
|
70
|
+
as is."""
|
|
71
|
+
out = ['"']
|
|
72
|
+
for ch in s:
|
|
73
|
+
o = ord(ch)
|
|
74
|
+
if ch == '"':
|
|
75
|
+
out.append('\\"')
|
|
76
|
+
elif ch == "\\":
|
|
77
|
+
out.append("\\\\")
|
|
78
|
+
elif o < 0x20:
|
|
79
|
+
out.append(_SHORT.get(o, f"\\u{o:04x}"))
|
|
80
|
+
elif 0xD800 <= o <= 0xDFFF:
|
|
81
|
+
raise NotCanonicalizable("lone surrogate in string")
|
|
82
|
+
else:
|
|
83
|
+
out.append(ch)
|
|
84
|
+
out.append('"')
|
|
85
|
+
return "".join(out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def jcs(value) -> str:
|
|
89
|
+
"""Serialize `value` per RFC 8785."""
|
|
90
|
+
if value is None:
|
|
91
|
+
return "null"
|
|
92
|
+
if value is True:
|
|
93
|
+
return "true"
|
|
94
|
+
if value is False:
|
|
95
|
+
return "false"
|
|
96
|
+
if isinstance(value, int):
|
|
97
|
+
# JCS numbers are IEEE doubles: an integer beyond 2^53 is what a
|
|
98
|
+
# JSON parser in any language rounds it to
|
|
99
|
+
if abs(value) > _MAX_SAFE:
|
|
100
|
+
return _es_number(float(value))
|
|
101
|
+
return str(value)
|
|
102
|
+
if isinstance(value, float):
|
|
103
|
+
if math.isnan(value) or math.isinf(value):
|
|
104
|
+
raise NotCanonicalizable("NaN and Infinity are not JSON")
|
|
105
|
+
return _es_number(value)
|
|
106
|
+
if isinstance(value, str):
|
|
107
|
+
return _string(value)
|
|
108
|
+
if isinstance(value, (list, tuple)):
|
|
109
|
+
return "[" + ",".join(jcs(v) for v in value) + "]"
|
|
110
|
+
if isinstance(value, dict):
|
|
111
|
+
items = []
|
|
112
|
+
for k in sorted(value, key=lambda k: str(k).encode("utf-16-be")):
|
|
113
|
+
if not isinstance(k, str):
|
|
114
|
+
raise NotCanonicalizable(f"object key is not a string: {k!r}")
|
|
115
|
+
items.append(_string(k) + ":" + jcs(value[k]))
|
|
116
|
+
return "{" + ",".join(items) + "}"
|
|
117
|
+
raise NotCanonicalizable(f"not a JSON value: {type(value).__name__}")
|
app/ledger/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from .fold import fold_approvals
|
|
2
|
+
from .hashing import GENESIS, canonical, chain_hash
|
|
3
|
+
from .store import (
|
|
4
|
+
DEFAULT_AGENT,
|
|
5
|
+
DEFAULT_TENANT,
|
|
6
|
+
GOVERNANCE_CHAIN,
|
|
7
|
+
GOVERNANCE_TYPES,
|
|
8
|
+
ActionMismatch,
|
|
9
|
+
AlreadyDecided,
|
|
10
|
+
GrantRefused,
|
|
11
|
+
IdempotencyMismatch,
|
|
12
|
+
Ledger,
|
|
13
|
+
RunOwnedByAnotherAgent,
|
|
14
|
+
SecondReviewerRequired,
|
|
15
|
+
UnknownApproval,
|
|
16
|
+
UnsupportedPayload,
|
|
17
|
+
chain_for,
|
|
18
|
+
)
|
|
19
|
+
from .verify import verify_chain
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_AGENT",
|
|
23
|
+
"DEFAULT_TENANT",
|
|
24
|
+
"GENESIS",
|
|
25
|
+
"GOVERNANCE_CHAIN",
|
|
26
|
+
"GOVERNANCE_TYPES",
|
|
27
|
+
"ActionMismatch",
|
|
28
|
+
"AlreadyDecided",
|
|
29
|
+
"GrantRefused",
|
|
30
|
+
"IdempotencyMismatch",
|
|
31
|
+
"Ledger",
|
|
32
|
+
"RunOwnedByAnotherAgent",
|
|
33
|
+
"SecondReviewerRequired",
|
|
34
|
+
"UnknownApproval",
|
|
35
|
+
"UnsupportedPayload",
|
|
36
|
+
"canonical",
|
|
37
|
+
"chain_for",
|
|
38
|
+
"chain_hash",
|
|
39
|
+
"fold_approvals",
|
|
40
|
+
"verify_chain",
|
|
41
|
+
]
|
app/ledger/fold.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Approval state as a fold over the event log. No status column drifts
|
|
2
|
+
because there is no status column in the truth — only in the projection cache.
|
|
3
|
+
|
|
4
|
+
Events carry payload as the stored canonical TEXT (or an already-parsed
|
|
5
|
+
dict when folding in-memory events).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from .projection import LIFECYCLE
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fold_approvals(events: list[dict]) -> dict[str, dict]:
|
|
15
|
+
approvals: dict[str, dict] = {}
|
|
16
|
+
for e in events:
|
|
17
|
+
p = e["payload"]
|
|
18
|
+
if isinstance(p, str):
|
|
19
|
+
p = json.loads(p)
|
|
20
|
+
if e["type"] == "approval_requested":
|
|
21
|
+
approvals[p["approval_id"]] = {
|
|
22
|
+
**p,
|
|
23
|
+
"status": "pending",
|
|
24
|
+
"requested_ts_ms": e["ts_ms"],
|
|
25
|
+
"reviewer": None,
|
|
26
|
+
"reason": None,
|
|
27
|
+
"decided_ts_ms": None,
|
|
28
|
+
"annotations": [],
|
|
29
|
+
}
|
|
30
|
+
elif e["type"] == "approval_endorsed":
|
|
31
|
+
# dual control: the first approval; the approval stays pending
|
|
32
|
+
a = approvals.get(p["approval_id"])
|
|
33
|
+
if a and a["status"] == "pending":
|
|
34
|
+
a["endorsed_by"] = p.get("reviewer")
|
|
35
|
+
a["endorsed_ts_ms"] = e["ts_ms"]
|
|
36
|
+
elif e["type"] in ("approval_granted", "approval_denied") \
|
|
37
|
+
or e["type"] in LIFECYCLE:
|
|
38
|
+
a = approvals.get(p["approval_id"])
|
|
39
|
+
if a and a["status"] == "pending": # first terminal event wins
|
|
40
|
+
a["status"] = ("granted" if e["type"] == "approval_granted"
|
|
41
|
+
else "denied" if e["type"] == "approval_denied"
|
|
42
|
+
else LIFECYCLE[e["type"]])
|
|
43
|
+
a["reviewer"] = p.get("reviewer")
|
|
44
|
+
a["reason"] = p.get("reason")
|
|
45
|
+
a["decided_ts_ms"] = e["ts_ms"]
|
|
46
|
+
if e["type"] == "approval_granted" and p.get("grant"):
|
|
47
|
+
a["grant"] = dict(p["grant"])
|
|
48
|
+
elif e["type"] == "grant_redeemed":
|
|
49
|
+
a = approvals.get(p["approval_id"])
|
|
50
|
+
g = a.get("grant") if a else None
|
|
51
|
+
if (g and g.get("nonce") == p.get("nonce")
|
|
52
|
+
and g.get("redeemed_ts_ms") is None):
|
|
53
|
+
g["redeemed_ts_ms"] = e["ts_ms"]
|
|
54
|
+
g["redeemed_by"] = p.get("executor")
|
|
55
|
+
if p.get("executor_instance"):
|
|
56
|
+
g["redeemed_instance"] = p["executor_instance"]
|
|
57
|
+
elif e["type"] == "incident_reported":
|
|
58
|
+
a = approvals.get(p["approval_id"])
|
|
59
|
+
if a is not None:
|
|
60
|
+
a["annotations"].append({
|
|
61
|
+
"reviewer": p.get("reviewer"),
|
|
62
|
+
"note": f"incident ({p.get('severity')}): "
|
|
63
|
+
f"{p.get('summary')}",
|
|
64
|
+
"kind": "incident", "severity": p.get("severity"),
|
|
65
|
+
"ts_ms": e["ts_ms"]})
|
|
66
|
+
elif e["type"] == "approval_annotated":
|
|
67
|
+
a = approvals.get(p["approval_id"])
|
|
68
|
+
if a:
|
|
69
|
+
a["annotations"].append({
|
|
70
|
+
"reviewer": p.get("reviewer"),
|
|
71
|
+
"note": p.get("note"),
|
|
72
|
+
"ts_ms": e["ts_ms"],
|
|
73
|
+
})
|
|
74
|
+
return approvals
|
app/ledger/hashing.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Chain hashing, byte-identical to the walking skeleton (../../server.py).
|
|
2
|
+
|
|
3
|
+
Exports built here must verify with the reference ../../verify.py.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
GENESIS = "genesis"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def canonical(payload: dict) -> str:
|
|
14
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def chain_hash(seq: int, ts_ms: int, type: str, payload_json: str, prev_hash: str) -> str:
|
|
18
|
+
line = f"{seq}|{ts_ms}|{type}|{payload_json}|{prev_hash}"
|
|
19
|
+
return hashlib.sha256(line.encode("utf-8")).hexdigest()
|
app/ledger/projection.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""approvals_view (+ the F24 governance folds): queryable caches over the log.
|
|
2
|
+
|
|
3
|
+
Updated inside the append transaction; rebuild_projection() proves they are
|
|
4
|
+
pure caches by truncating and re-folding the whole log (all tenants).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from psycopg import AsyncConnection
|
|
11
|
+
|
|
12
|
+
DEFAULT_TENANT = "default"
|
|
13
|
+
DEFAULT_AGENT = "default"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# terminal transitions other than a decision: event type -> status
|
|
17
|
+
LIFECYCLE = {
|
|
18
|
+
"approval_cancelled": "cancelled",
|
|
19
|
+
"approval_expired": "expired",
|
|
20
|
+
"approval_superseded": "superseded",
|
|
21
|
+
"approval_changes_requested": "changes_requested",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def apply_event(conn: AsyncConnection, type: str, payload: dict,
|
|
26
|
+
ts_ms: int, tenant: str = DEFAULT_TENANT,
|
|
27
|
+
agent_id: str | None = None,
|
|
28
|
+
unit: str | None = None,
|
|
29
|
+
event_hash: str | None = None) -> None:
|
|
30
|
+
if type == "approval_requested":
|
|
31
|
+
await conn.execute(
|
|
32
|
+
"""
|
|
33
|
+
INSERT INTO approvals_view
|
|
34
|
+
(tenant_id, approval_id, run_id, call_id, tool, arguments,
|
|
35
|
+
reasoning, framework, callback_url, status, requested_ts_ms,
|
|
36
|
+
deadline_ms, agent_id, unit, disposition, action_digest,
|
|
37
|
+
policy, rule_result, supersedes, digest_alg)
|
|
38
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, 'pending',
|
|
39
|
+
%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s)
|
|
40
|
+
ON CONFLICT (tenant_id, approval_id) DO NOTHING
|
|
41
|
+
""",
|
|
42
|
+
(
|
|
43
|
+
tenant,
|
|
44
|
+
payload["approval_id"],
|
|
45
|
+
payload["run_id"],
|
|
46
|
+
payload["call_id"],
|
|
47
|
+
payload["tool"],
|
|
48
|
+
json.dumps(payload.get("arguments") or {}),
|
|
49
|
+
payload.get("reasoning"),
|
|
50
|
+
payload.get("framework"),
|
|
51
|
+
payload.get("callback_url"),
|
|
52
|
+
ts_ms,
|
|
53
|
+
payload.get("deadline_ms"),
|
|
54
|
+
agent_id or DEFAULT_AGENT,
|
|
55
|
+
unit,
|
|
56
|
+
payload.get("disposition"),
|
|
57
|
+
payload.get("action_digest"),
|
|
58
|
+
json.dumps(payload["policy"]) if payload.get("policy") else None,
|
|
59
|
+
(json.dumps(payload["rule_result"])
|
|
60
|
+
if payload.get("rule_result") is not None else None),
|
|
61
|
+
payload.get("supersedes"),
|
|
62
|
+
payload.get("digest_alg"),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
elif type in LIFECYCLE:
|
|
66
|
+
await conn.execute(
|
|
67
|
+
"""
|
|
68
|
+
UPDATE approvals_view
|
|
69
|
+
SET status = %s, reviewer = %s, reason = %s, decided_ts_ms = %s
|
|
70
|
+
WHERE tenant_id = %s AND approval_id = %s AND status = 'pending'
|
|
71
|
+
""",
|
|
72
|
+
(LIFECYCLE[type], payload.get("reviewer"), payload.get("reason"),
|
|
73
|
+
ts_ms, tenant, payload["approval_id"]),
|
|
74
|
+
)
|
|
75
|
+
elif type == "approval_endorsed":
|
|
76
|
+
# dual control: cache the first approval; status stays pending
|
|
77
|
+
await conn.execute(
|
|
78
|
+
"""
|
|
79
|
+
UPDATE approvals_view
|
|
80
|
+
SET endorsed_by = %s, endorsed_ts_ms = %s
|
|
81
|
+
WHERE tenant_id = %s AND approval_id = %s AND status = 'pending'
|
|
82
|
+
""",
|
|
83
|
+
(payload.get("reviewer"), ts_ms, tenant, payload["approval_id"]),
|
|
84
|
+
)
|
|
85
|
+
elif type in ("approval_granted", "approval_denied"):
|
|
86
|
+
status = "granted" if type == "approval_granted" else "denied"
|
|
87
|
+
grant = payload.get("grant") if type == "approval_granted" else None
|
|
88
|
+
if isinstance(grant, dict) and grant.get("nonce"):
|
|
89
|
+
# the execution grant born with the decision: cached on the
|
|
90
|
+
# row so the agent's poll returns it and redeem checks it
|
|
91
|
+
await conn.execute(
|
|
92
|
+
"""
|
|
93
|
+
UPDATE approvals_view
|
|
94
|
+
SET status = %s, reviewer = %s, reason = %s, decided_ts_ms = %s,
|
|
95
|
+
grant_nonce = %s, grant_issued_ms = %s,
|
|
96
|
+
grant_expires_ms = %s, grant_key_id = %s,
|
|
97
|
+
grant_signature = %s
|
|
98
|
+
WHERE tenant_id = %s AND approval_id = %s AND status = 'pending'
|
|
99
|
+
""",
|
|
100
|
+
(status, payload.get("reviewer"), payload.get("reason"), ts_ms,
|
|
101
|
+
grant["nonce"], grant.get("issued_ms"),
|
|
102
|
+
grant.get("expires_ms"), grant.get("key_id"),
|
|
103
|
+
grant.get("signature"), tenant, payload["approval_id"]),
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
await conn.execute(
|
|
107
|
+
"""
|
|
108
|
+
UPDATE approvals_view
|
|
109
|
+
SET status = %s, reviewer = %s, reason = %s, decided_ts_ms = %s
|
|
110
|
+
WHERE tenant_id = %s AND approval_id = %s AND status = 'pending'
|
|
111
|
+
""",
|
|
112
|
+
(status, payload.get("reviewer"), payload.get("reason"), ts_ms,
|
|
113
|
+
tenant, payload["approval_id"]),
|
|
114
|
+
)
|
|
115
|
+
elif type == "grant_redeemed":
|
|
116
|
+
# single use: the first redemption of the grant's nonce wins
|
|
117
|
+
await conn.execute(
|
|
118
|
+
"""
|
|
119
|
+
UPDATE approvals_view
|
|
120
|
+
SET grant_redeemed_ts_ms = %s, grant_redeemed_by = %s,
|
|
121
|
+
grant_redeemed_instance = %s
|
|
122
|
+
WHERE tenant_id = %s AND approval_id = %s AND grant_nonce = %s
|
|
123
|
+
AND grant_redeemed_ts_ms IS NULL
|
|
124
|
+
""",
|
|
125
|
+
(ts_ms, payload.get("executor"), payload.get("executor_instance"),
|
|
126
|
+
tenant, payload["approval_id"], payload.get("nonce")),
|
|
127
|
+
)
|
|
128
|
+
elif type == "approval_annotated":
|
|
129
|
+
await conn.execute(
|
|
130
|
+
"""
|
|
131
|
+
UPDATE approvals_view
|
|
132
|
+
SET annotations = annotations || %s::jsonb
|
|
133
|
+
WHERE tenant_id = %s AND approval_id = %s
|
|
134
|
+
""",
|
|
135
|
+
(
|
|
136
|
+
json.dumps([{
|
|
137
|
+
"reviewer": payload.get("reviewer"),
|
|
138
|
+
"note": payload.get("note"),
|
|
139
|
+
"ts_ms": ts_ms,
|
|
140
|
+
}]),
|
|
141
|
+
tenant,
|
|
142
|
+
payload["approval_id"],
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
elif type == "run_event":
|
|
146
|
+
# usage_view: one row per model decision, the SQL-aggregable form
|
|
147
|
+
# of what budgets and the spend report used to fold in Python
|
|
148
|
+
inner = payload.get("event") or {}
|
|
149
|
+
if isinstance(inner, dict) and inner.get("type") == "model_decision":
|
|
150
|
+
ip = inner.get("payload") or {}
|
|
151
|
+
usage = ip.get("usage") or {}
|
|
152
|
+
tools = sorted({str(c["name"]) for c in (ip.get("tool_calls") or [])
|
|
153
|
+
if isinstance(c, dict) and c.get("name")})
|
|
154
|
+
await conn.execute(
|
|
155
|
+
"""
|
|
156
|
+
INSERT INTO usage_view
|
|
157
|
+
(tenant_id, run_id, ts_ms, model, input_tokens,
|
|
158
|
+
output_tokens, tools)
|
|
159
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
160
|
+
""",
|
|
161
|
+
(tenant, payload.get("run_id"), ts_ms,
|
|
162
|
+
str(ip.get("model") or "unknown"),
|
|
163
|
+
_int(usage.get("input_tokens")),
|
|
164
|
+
_int(usage.get("output_tokens")), tools),
|
|
165
|
+
)
|
|
166
|
+
elif type == "incident_reported":
|
|
167
|
+
# surfaced on the case as a typed annotation; counted by the
|
|
168
|
+
# rollout track record straight from the event
|
|
169
|
+
await conn.execute(
|
|
170
|
+
"""
|
|
171
|
+
UPDATE approvals_view
|
|
172
|
+
SET annotations = annotations || %s::jsonb
|
|
173
|
+
WHERE tenant_id = %s AND approval_id = %s
|
|
174
|
+
""",
|
|
175
|
+
(
|
|
176
|
+
json.dumps([{
|
|
177
|
+
"reviewer": payload.get("reviewer"),
|
|
178
|
+
"note": f"incident ({payload.get('severity')}): "
|
|
179
|
+
f"{payload.get('summary')}",
|
|
180
|
+
"kind": "incident",
|
|
181
|
+
"severity": payload.get("severity"),
|
|
182
|
+
"ts_ms": ts_ms,
|
|
183
|
+
}]),
|
|
184
|
+
tenant,
|
|
185
|
+
payload["approval_id"],
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
elif type == "policy_set":
|
|
189
|
+
# last-write-wins per (tenant, action): the fold of policy_set events
|
|
190
|
+
await conn.execute(
|
|
191
|
+
"""
|
|
192
|
+
INSERT INTO policy_view
|
|
193
|
+
(tenant_id, action, requires_approval, reversible, mode,
|
|
194
|
+
allow, deny, approver_groups, require_second_approval,
|
|
195
|
+
sample_n, updated_by, reason, updated_ts_ms,
|
|
196
|
+
risk, blast_radius, version)
|
|
197
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb,
|
|
198
|
+
%s, %s, %s, %s, %s, %s, %s, %s)
|
|
199
|
+
ON CONFLICT (tenant_id, action) DO UPDATE SET
|
|
200
|
+
requires_approval = EXCLUDED.requires_approval,
|
|
201
|
+
reversible = EXCLUDED.reversible,
|
|
202
|
+
mode = EXCLUDED.mode,
|
|
203
|
+
allow = EXCLUDED.allow,
|
|
204
|
+
deny = EXCLUDED.deny,
|
|
205
|
+
approver_groups = EXCLUDED.approver_groups,
|
|
206
|
+
require_second_approval = EXCLUDED.require_second_approval,
|
|
207
|
+
sample_n = EXCLUDED.sample_n,
|
|
208
|
+
updated_by = EXCLUDED.updated_by,
|
|
209
|
+
reason = EXCLUDED.reason,
|
|
210
|
+
updated_ts_ms = EXCLUDED.updated_ts_ms,
|
|
211
|
+
risk = EXCLUDED.risk,
|
|
212
|
+
blast_radius = EXCLUDED.blast_radius,
|
|
213
|
+
version = EXCLUDED.version
|
|
214
|
+
""",
|
|
215
|
+
(
|
|
216
|
+
tenant,
|
|
217
|
+
payload["action"],
|
|
218
|
+
bool(payload["requires_approval"]),
|
|
219
|
+
bool(payload.get("reversible", False)),
|
|
220
|
+
payload["mode"],
|
|
221
|
+
json.dumps(payload.get("allow") or []),
|
|
222
|
+
json.dumps(payload.get("deny") or []),
|
|
223
|
+
json.dumps(payload.get("approver_groups") or []),
|
|
224
|
+
bool(payload.get("require_second_approval", False)),
|
|
225
|
+
int(payload.get("sample_n") or 0),
|
|
226
|
+
payload.get("by"),
|
|
227
|
+
payload.get("reason"),
|
|
228
|
+
ts_ms,
|
|
229
|
+
payload.get("risk") or ("medium" if payload.get("reversible")
|
|
230
|
+
else "high"),
|
|
231
|
+
payload.get("blast_radius"),
|
|
232
|
+
event_hash[:12] if event_hash else None,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
elif type == "budget_set":
|
|
236
|
+
# last-write-wins per (tenant, scope, key): the fold of budget_set (F23)
|
|
237
|
+
await conn.execute(
|
|
238
|
+
"""
|
|
239
|
+
INSERT INTO budget_view
|
|
240
|
+
(tenant_id, scope, key, max_usd, max_tokens, window_kind,
|
|
241
|
+
on_exceed, updated_by, reason, updated_ts_ms)
|
|
242
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
243
|
+
ON CONFLICT (tenant_id, scope, key) DO UPDATE SET
|
|
244
|
+
max_usd = EXCLUDED.max_usd,
|
|
245
|
+
max_tokens = EXCLUDED.max_tokens,
|
|
246
|
+
window_kind = EXCLUDED.window_kind,
|
|
247
|
+
on_exceed = EXCLUDED.on_exceed,
|
|
248
|
+
updated_by = EXCLUDED.updated_by,
|
|
249
|
+
reason = EXCLUDED.reason,
|
|
250
|
+
updated_ts_ms = EXCLUDED.updated_ts_ms
|
|
251
|
+
""",
|
|
252
|
+
(tenant, payload["scope"], payload["key"], payload.get("max_usd"),
|
|
253
|
+
payload.get("max_tokens"), payload["window"],
|
|
254
|
+
payload["on_exceed"], payload.get("by"), payload.get("reason"),
|
|
255
|
+
ts_ms),
|
|
256
|
+
)
|
|
257
|
+
elif type == "eval_recorded":
|
|
258
|
+
await conn.execute(
|
|
259
|
+
"INSERT INTO eval_history (tenant_id, kind, agent, version, "
|
|
260
|
+
"detail, ts_ms) VALUES (%s, 'eval', %s, %s, %s::jsonb, %s)",
|
|
261
|
+
(tenant, payload["agent"], payload["version"],
|
|
262
|
+
json.dumps({"profile": payload.get("profile"),
|
|
263
|
+
"scenarios": payload.get("scenarios"),
|
|
264
|
+
"by": payload.get("by")}), ts_ms),
|
|
265
|
+
)
|
|
266
|
+
elif type == "gate_decision":
|
|
267
|
+
await conn.execute(
|
|
268
|
+
"INSERT INTO eval_history (tenant_id, kind, agent, version, "
|
|
269
|
+
"detail, ts_ms) VALUES (%s, 'gate', %s, %s, %s::jsonb, %s)",
|
|
270
|
+
(tenant, payload["agent"], payload["version"],
|
|
271
|
+
json.dumps({k: payload.get(k) for k in
|
|
272
|
+
("passed", "baseline_version", "blocks", "override",
|
|
273
|
+
"by", "reason")}), ts_ms),
|
|
274
|
+
)
|
|
275
|
+
# gate_view is the latest verdict; last-write-wins (an override is a
|
|
276
|
+
# newer gate_decision)
|
|
277
|
+
await conn.execute(
|
|
278
|
+
"""
|
|
279
|
+
INSERT INTO gate_view
|
|
280
|
+
(tenant_id, agent, version, passed, baseline_version, blocks,
|
|
281
|
+
override, decided_by, reason, decided_ts_ms)
|
|
282
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
|
|
283
|
+
ON CONFLICT (tenant_id, agent, version) DO UPDATE SET
|
|
284
|
+
passed = EXCLUDED.passed,
|
|
285
|
+
baseline_version = EXCLUDED.baseline_version,
|
|
286
|
+
blocks = EXCLUDED.blocks,
|
|
287
|
+
override = EXCLUDED.override,
|
|
288
|
+
decided_by = EXCLUDED.decided_by,
|
|
289
|
+
reason = EXCLUDED.reason,
|
|
290
|
+
decided_ts_ms = EXCLUDED.decided_ts_ms
|
|
291
|
+
""",
|
|
292
|
+
(tenant, payload["agent"], payload["version"],
|
|
293
|
+
bool(payload.get("passed", False)),
|
|
294
|
+
payload.get("baseline_version"),
|
|
295
|
+
json.dumps(payload.get("blocks") or []),
|
|
296
|
+
bool(payload.get("override", False)),
|
|
297
|
+
payload.get("by"), payload.get("reason"), ts_ms),
|
|
298
|
+
)
|
|
299
|
+
elif type == "agent_registered":
|
|
300
|
+
# last-write-wins per (tenant, agent): the agent-to-unit map (F24)
|
|
301
|
+
await conn.execute(
|
|
302
|
+
"""
|
|
303
|
+
INSERT INTO agents_view
|
|
304
|
+
(tenant_id, agent, unit, updated_by, reason, updated_ts_ms)
|
|
305
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
306
|
+
ON CONFLICT (tenant_id, agent) DO UPDATE SET
|
|
307
|
+
unit = EXCLUDED.unit,
|
|
308
|
+
updated_by = EXCLUDED.updated_by,
|
|
309
|
+
reason = EXCLUDED.reason,
|
|
310
|
+
updated_ts_ms = EXCLUDED.updated_ts_ms
|
|
311
|
+
""",
|
|
312
|
+
(tenant, payload["agent"], payload.get("unit"),
|
|
313
|
+
payload.get("by"), payload.get("reason"), ts_ms),
|
|
314
|
+
)
|
|
315
|
+
elif type == "evidence_set":
|
|
316
|
+
# last-write-wins per (tenant, agent): the evidence profile (F24)
|
|
317
|
+
await conn.execute(
|
|
318
|
+
"""
|
|
319
|
+
INSERT INTO evidence_view
|
|
320
|
+
(tenant_id, agent, signing_backend, key_id, capture,
|
|
321
|
+
retention_days, updated_by, reason, updated_ts_ms)
|
|
322
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
323
|
+
ON CONFLICT (tenant_id, agent) DO UPDATE SET
|
|
324
|
+
signing_backend = EXCLUDED.signing_backend,
|
|
325
|
+
key_id = EXCLUDED.key_id,
|
|
326
|
+
capture = EXCLUDED.capture,
|
|
327
|
+
retention_days = EXCLUDED.retention_days,
|
|
328
|
+
updated_by = EXCLUDED.updated_by,
|
|
329
|
+
reason = EXCLUDED.reason,
|
|
330
|
+
updated_ts_ms = EXCLUDED.updated_ts_ms
|
|
331
|
+
""",
|
|
332
|
+
(tenant, payload["agent"], payload["signing_backend"],
|
|
333
|
+
payload.get("key_id"), payload.get("capture", "full"),
|
|
334
|
+
payload.get("retention_days"), payload.get("by"),
|
|
335
|
+
payload.get("reason"), ts_ms),
|
|
336
|
+
)
|
|
337
|
+
elif type == "admin_action" and payload.get("action") == "set_config":
|
|
338
|
+
# runtime-tunable org config (e.g. admin_can_read_activity) is itself
|
|
339
|
+
# an event-sourced fold, so a rebuild reproduces the posture
|
|
340
|
+
await conn.execute(
|
|
341
|
+
"""
|
|
342
|
+
INSERT INTO config_view (tenant_id, key, value, updated_ts_ms)
|
|
343
|
+
VALUES (%s, %s, %s::jsonb, %s)
|
|
344
|
+
ON CONFLICT (tenant_id, key) DO UPDATE SET
|
|
345
|
+
value = EXCLUDED.value,
|
|
346
|
+
updated_ts_ms = EXCLUDED.updated_ts_ms
|
|
347
|
+
""",
|
|
348
|
+
(tenant, payload["key"], json.dumps(payload.get("value")), ts_ms),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
_VIEW_TABLES = ("approvals_view", "policy_view", "budget_view", "gate_view",
|
|
353
|
+
"eval_history", "agents_view", "evidence_view", "config_view",
|
|
354
|
+
"usage_view")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _int(v) -> int:
|
|
358
|
+
try:
|
|
359
|
+
return int(v or 0)
|
|
360
|
+
except (TypeError, ValueError):
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def rebuild_projection(conn: AsyncConnection,
|
|
365
|
+
tenant: str | None = None) -> None:
|
|
366
|
+
"""Re-fold the projections from the log. With `tenant`, only that
|
|
367
|
+
tenant's rows are cleared and re-folded: one tenant's admin never
|
|
368
|
+
rebuilds (or even touches) another tenant's cache. Without it, the
|
|
369
|
+
whole store (ops tooling and tests)."""
|
|
370
|
+
if tenant is None:
|
|
371
|
+
for table in _VIEW_TABLES:
|
|
372
|
+
await conn.execute(
|
|
373
|
+
f"TRUNCATE {table}"
|
|
374
|
+
+ (" RESTART IDENTITY"
|
|
375
|
+
if table in ("eval_history", "usage_view") else ""))
|
|
376
|
+
cur = await conn.execute(
|
|
377
|
+
"SELECT tenant_id, type, payload, ts_ms, agent_id, unit, hash "
|
|
378
|
+
"FROM events ORDER BY rowid")
|
|
379
|
+
else:
|
|
380
|
+
for table in _VIEW_TABLES:
|
|
381
|
+
await conn.execute(
|
|
382
|
+
f"DELETE FROM {table} WHERE tenant_id = %s", (tenant,))
|
|
383
|
+
cur = await conn.execute(
|
|
384
|
+
"SELECT tenant_id, type, payload, ts_ms, agent_id, unit, hash "
|
|
385
|
+
"FROM events WHERE tenant_id = %s ORDER BY rowid", (tenant,))
|
|
386
|
+
rows = await cur.fetchall()
|
|
387
|
+
for tenant_id, type_, payload, ts_ms, agent_id, unit, h in rows:
|
|
388
|
+
await apply_event(conn, type_, json.loads(payload), ts_ms, tenant_id,
|
|
389
|
+
agent_id=agent_id, unit=unit, event_hash=h)
|