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/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()
@@ -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)