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
@@ -0,0 +1,576 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from fastapi.responses import JSONResponse
5
+
6
+ from ..auth import (
7
+ activity_scope,
8
+ agent_credential,
9
+ agent_pin,
10
+ is_agent_plane,
11
+ require_agent,
12
+ require_reader,
13
+ require_reviewer,
14
+ resolve_agent,
15
+ resolve_tenant,
16
+ reviewer_role,
17
+ reviewer_string,
18
+ scope_units,
19
+ visible_to,
20
+ )
21
+ from ..costgov import COST_REVIEWER
22
+ from ..faults import fault_point
23
+ from ..idempotency import idempotency_of
24
+ from ..ledger import (
25
+ ActionMismatch,
26
+ AlreadyDecided,
27
+ GrantRefused,
28
+ IdempotencyMismatch,
29
+ Ledger,
30
+ RunOwnedByAnotherAgent,
31
+ SecondReviewerRequired,
32
+ UnknownApproval,
33
+ )
34
+ from ..policy import (
35
+ POLICY_REVIEWER,
36
+ NotAnApprover,
37
+ case_controls,
38
+ sampled_for_review,
39
+ )
40
+ from ..schemas import (
41
+ Annotation,
42
+ Cancel,
43
+ ChangesRequest,
44
+ CreateApproval,
45
+ Decision,
46
+ Incident,
47
+ Redeem,
48
+ )
49
+
50
+ router = APIRouter(prefix="/api/approvals")
51
+
52
+
53
+ def _ledger(request: Request) -> Ledger:
54
+ return request.app.state.ledger
55
+
56
+
57
+ async def _existing(ledger: Ledger, approval_id: str, tenant: str) -> dict:
58
+ """Re-read an approval this request already touched: it exists."""
59
+ a = await ledger.approval(approval_id, tenant)
60
+ assert a is not None
61
+ return a
62
+
63
+
64
+ def redact_grant(a: dict) -> dict:
65
+ """The approval as the human plane sees it: the execution grant's
66
+ metadata (issued, expires, redeemed by whom) stays — it is the case's
67
+ provenance — but the signature, the bearer half of a capability to
68
+ run one consequential action, is handed only to the agent plane."""
69
+ if a and a.get("grant") and "signature" in a["grant"]:
70
+ return {**a, "grant": {k: v for k, v in a["grant"].items()
71
+ if k != "signature"}}
72
+ return a
73
+
74
+
75
+ async def _present(request: Request, a: dict) -> dict:
76
+ return a if await is_agent_plane(request) else redact_grant(a)
77
+
78
+
79
+ def _reviewer_or_400(identity: dict | None, typed: str) -> str:
80
+ if identity is not None and identity.get("token_id"):
81
+ # issued-token operator: the typed name says WHO sat at the
82
+ # keyboard, the binding says WHICH revocable credential proved it
83
+ if not typed.strip():
84
+ raise HTTPException(400, "reviewer is required")
85
+ label = identity.get("token_label")
86
+ bind = f"token:{identity['token_id']}" + (f" {label}" if label else "")
87
+ return f"{typed.strip()} ({bind})"
88
+ if identity is not None:
89
+ return reviewer_string(identity)
90
+ if not typed.strip():
91
+ raise HTTPException(400, "reviewer is required")
92
+ return typed
93
+
94
+
95
+ @router.post("", dependencies=[Depends(require_agent)])
96
+ async def create(request: Request, body: CreateApproval):
97
+ from ..netguard import UnsafeCallbackURL, validate_callback_url
98
+ from ..sla import resolve_deadline_ms
99
+
100
+ # SSRF guard: refuse a callback_url pointing at internal/metadata targets
101
+ # before we ever POST a decision to it.
102
+ if body.callback_url:
103
+ try:
104
+ validate_callback_url(
105
+ body.callback_url,
106
+ request.app.state.settings.webhook_allow_private)
107
+ except UnsafeCallbackURL as e:
108
+ raise HTTPException(400, str(e)) from None
109
+
110
+ tenant = await resolve_tenant(request)
111
+ # non-spoofable: agent + unit come from the service token record,
112
+ # resolved server-side (F24) — never from the request body
113
+ agent_id, unit = await resolve_agent(request)
114
+ ledger = _ledger(request)
115
+
116
+ # live policy supersedes static config: an action whose current policy
117
+ # does not require approval is auto-granted by system:policy so the
118
+ # agent's loop resolves immediately; unknown actions fall back to the
119
+ # configured fail-safe (require approval).
120
+ effective = await request.app.state.policy.effective(body.tool, tenant)
121
+
122
+ # F23: cost governance can only TIGHTEN the safety decision, never weaken
123
+ # it. Over budget with on_exceed=gate forces a human even if policy said
124
+ # autonomous; on_exceed=deny refuses the action outright. Under budget (or
125
+ # the open no-budget posture) it adds nothing.
126
+ cost = await request.app.state.cost.check(body.tool, tenant,
127
+ run_id=body.run_id)
128
+ cost_denies = cost["exceeded"] and cost["on_exceed"] == "deny"
129
+ cost_gates = cost["exceeded"] and cost["on_exceed"] == "gate"
130
+ # sampled mode: a deterministic 1-in-N goes to a human. Computed on
131
+ # run_id:call_id BEFORE anything is created, so a retry of the same
132
+ # call gets the same answer — and keyed by a server-side salt the
133
+ # agent plane cannot read, so the agent cannot grind call ids that
134
+ # are never sampled.
135
+ sampled_in = (effective["mode"] == "sampled" and sampled_for_review(
136
+ f"{body.run_id}:{body.call_id}", effective.get("sample_n") or 0,
137
+ await request.app.state.policy.sampling_salt(tenant)))
138
+ # dual control is the strongest gate: never auto-grantable
139
+ requires_approval = (effective["requires_approval"] or cost_gates
140
+ or sampled_in
141
+ or effective.get("require_second_approval", False))
142
+ # the evaluation is made ONCE, at request time, and stamped into the
143
+ # requested event: "deny" (over budget), "auto" (no gate), or "gated"
144
+ # (a human must decide)
145
+ disposition = ("deny" if cost_denies
146
+ else "gated" if requires_approval else "auto")
147
+ # the rule result, in words the reviewer reads: why this proposal is
148
+ # gated (or not), against which policy version
149
+ rule_result = []
150
+ if effective["source"] == "default":
151
+ rule_result.append("no policy rule: the configured default "
152
+ + ("requires approval"
153
+ if effective["requires_approval"]
154
+ else "auto-grants"))
155
+ else:
156
+ rule_result.append(f"policy mode {effective['mode']}: "
157
+ + ("requires approval"
158
+ if effective["requires_approval"]
159
+ else "no human gate"))
160
+ if effective.get("require_second_approval"):
161
+ rule_result.append("dual control: two different approvers")
162
+ if sampled_in:
163
+ rule_result.append(f"sampled: this 1-in-{effective.get('sample_n')} "
164
+ f"pick goes to a human")
165
+ if cost_gates:
166
+ rule_result.append(f"cost: over budget ({cost['scope']}:{cost['key']}, "
167
+ f"{cost['window']}) forces a human")
168
+ if cost_denies:
169
+ rule_result.append(f"cost: over budget ({cost['scope']}:{cost['key']}, "
170
+ f"{cost['window']}) denies")
171
+ policy_snapshot = request.app.state.policy.snapshot(effective)
172
+
173
+ deadline_ms = resolve_deadline_ms(request.app.state.settings, body.tool,
174
+ body.deadline_ms)
175
+ try:
176
+ approval, created = await ledger.create_approval(
177
+ run_id=body.run_id, call_id=body.call_id, tool=body.tool,
178
+ arguments=body.arguments, reasoning=body.reasoning,
179
+ framework=body.framework, deadline_ms=deadline_ms,
180
+ callback_url=body.callback_url, tenant=tenant,
181
+ agent_id=agent_id, unit=unit, disposition=disposition,
182
+ policy=policy_snapshot, rule_result=rule_result,
183
+ supersedes=body.supersedes,
184
+ pinned=(await agent_pin(request)) is not None)
185
+ except ActionMismatch as e:
186
+ # the id is taken by a DIFFERENT action: refuse rather than hand
187
+ # the caller the earlier decision. An accidental (or deliberate)
188
+ # id reuse must never attach an old approval to a new operation.
189
+ # (also: superseding another agent's proposal)
190
+ raise HTTPException(409, str(e)) from None
191
+ except RunOwnedByAnotherAgent as e:
192
+ # a pinned agent proposing into another agent's run (the same rule
193
+ # as run-event ingest: a run has one owner)
194
+ raise HTTPException(409, str(e)) from None
195
+ except UnknownApproval:
196
+ raise HTTPException(404, f"unknown approval to supersede: "
197
+ f"{body.supersedes}") from None
198
+ except AlreadyDecided as e:
199
+ raise HTTPException(409, f"cannot supersede {e.approval_id}: "
200
+ f"already {e.status}") from None
201
+ fault_point("after_append_commit_before_http")
202
+
203
+ # These auto-decisions are idempotent on the approval, NOT conditional on
204
+ # `created`: the requested-append and the decision are separate
205
+ # transactions, so a crash (or lost response) between them leaves the
206
+ # approval pending and the agent's idempotent retry (created=False) must
207
+ # still finish it. AlreadyDecided means a human or earlier retry won.
208
+ #
209
+ # A retry executes the STAMPED disposition, never a fresh evaluation:
210
+ # otherwise a policy relaxed (or a budget window rolled over) after an
211
+ # action was gated would let the agent re-POST the same create and turn
212
+ # a pending, human-queued action into an auto-approval. Events from
213
+ # before the stamp existed read as gated — the fail-safe: a human
214
+ # finishes what a crash left pending.
215
+ if not created:
216
+ disposition = (await ledger.requested_disposition(
217
+ approval["approval_id"], body.run_id, tenant)) or "gated"
218
+ if disposition == "deny" and approval["status"] == "pending":
219
+ if cost.get("exceeded"):
220
+ limit = cost.get("limit") or {}
221
+ spent = cost.get("spent") or {}
222
+ detail = (f"over budget: {cost['scope']}:{cost['key']} spent "
223
+ f"${(spent.get('usd') if spent else 0)} / "
224
+ f"{(spent.get('tokens') if spent else 0)} tok vs "
225
+ f"limit ${limit.get('usd')}/{limit.get('tokens')} tok "
226
+ f"({cost['window']})")
227
+ else:
228
+ # a retry after the window rolled over: the stamped deny still
229
+ # stands (the evaluation happened once, at request time)
230
+ detail = "over budget at request time (stamped disposition)"
231
+ try:
232
+ approval = await ledger.decide(
233
+ approval["approval_id"], approved=False,
234
+ reviewer=COST_REVIEWER, reason=detail, reviewer_role="cost",
235
+ tenant=tenant)
236
+ except AlreadyDecided:
237
+ approval = await _existing(ledger, approval["approval_id"], tenant)
238
+ elif disposition == "auto" and approval["status"] == "pending":
239
+ reason = f"auto-approved: policy mode={effective['mode']}"
240
+ if effective["mode"] == "sampled":
241
+ reason += (f" (passed 1-in-{effective.get('sample_n')} "
242
+ f"sampling; the pick is a salted HMAC over the "
243
+ f"approval id — the export discloses the salt)")
244
+ try:
245
+ approval = await ledger.decide(
246
+ approval["approval_id"], approved=True,
247
+ reviewer=POLICY_REVIEWER, reason=reason,
248
+ reviewer_role="policy", tenant=tenant)
249
+ except AlreadyDecided:
250
+ approval = await _existing(ledger, approval["approval_id"], tenant)
251
+ elif created and disposition == "gated":
252
+ # gated for a human. When sampling or cost forced the gate, put
253
+ # the why in front of the reviewer as an annotation.
254
+ if sampled_in:
255
+ await ledger.annotate(
256
+ approval["approval_id"], POLICY_REVIEWER,
257
+ f"sampled for human review: 1 in "
258
+ f"{effective.get('sample_n')} {body.tool} proposals is "
259
+ f"checked (policy mode=sampled; the pick is a salted HMAC "
260
+ f"over the approval id — the export discloses the salt)",
261
+ tenant=tenant)
262
+ approval = await _existing(ledger, approval["approval_id"], tenant)
263
+ if cost_gates:
264
+ limit = cost.get("limit") or {}
265
+ spent = cost.get("spent") or {}
266
+ await ledger.annotate(
267
+ approval["approval_id"], COST_REVIEWER,
268
+ f"over budget ({cost['scope']}:{cost['key']}, {cost['window']}): "
269
+ f"spent ${spent.get('usd')} / {spent.get('tokens')} tok vs "
270
+ f"limit ${limit.get('usd')}/{limit.get('tokens')} tok",
271
+ tenant=tenant)
272
+ approval = await _existing(ledger, approval["approval_id"], tenant)
273
+ for chat in getattr(request.app.state, "chat_routers", []):
274
+ chat.notify(approval)
275
+ return JSONResponse(approval, status_code=201 if created else 200)
276
+
277
+
278
+ # a client-supplied limit is clamped: an absurd value would fold the whole
279
+ # table per request. The web inbox pages with cursor at this size.
280
+ MAX_LIMIT = 500
281
+
282
+
283
+ @router.get("", dependencies=[Depends(require_reader)])
284
+ async def list_approvals(request: Request, status: str | None = None,
285
+ limit: int = 100, cursor: int = 0,
286
+ order: str = "asc", after: str | None = None):
287
+ """Unit-scoped, server-enforced (F24): the queue never shows — or
288
+ counts — another unit's work.
289
+
290
+ `order=desc` lists newest first. `after=<requested_ts_ms>:<approval_id>`
291
+ (the last row of the previous page) is a keyset cursor: stable while
292
+ rows are being decided, where the `cursor` offset skips or repeats."""
293
+ if order not in ("asc", "desc"):
294
+ raise HTTPException(400, "order must be asc or desc")
295
+ keyset = None
296
+ if after is not None:
297
+ ts, sep, last_id = after.partition(":")
298
+ if not sep or not last_id or not ts.lstrip("-").isdigit():
299
+ raise HTTPException(
300
+ 400, "after must be <requested_ts_ms>:<approval_id>")
301
+ keyset = (int(ts), last_id)
302
+ scope = await activity_scope(request)
303
+ rows = await _ledger(request).approvals(
304
+ status=status, limit=max(1, min(limit, MAX_LIMIT)),
305
+ offset=max(0, cursor), newest_first=order == "desc", after=keyset,
306
+ tenant=await resolve_tenant(request),
307
+ units=scope_units(scope),
308
+ agent=scope.get("agent") if scope else None)
309
+ if await is_agent_plane(request):
310
+ return rows
311
+ return [redact_grant(a) for a in rows]
312
+
313
+
314
+ @router.get("/pending-count", dependencies=[Depends(require_reader)])
315
+ async def pending_count(request: Request):
316
+ """The nav badge, unit-scoped: volume is information too."""
317
+ scope = await activity_scope(request)
318
+ n = await _ledger(request).pending_count(
319
+ tenant=await resolve_tenant(request), units=scope_units(scope),
320
+ agent=scope.get("agent") if scope else None)
321
+ return {"pending": n}
322
+
323
+
324
+ @router.get("/{approval_id}", dependencies=[Depends(require_reader)])
325
+ async def get_one(request: Request, approval_id: str):
326
+ a = await _ledger(request).approval(approval_id,
327
+ tenant=await resolve_tenant(request))
328
+ scope = await activity_scope(request)
329
+ if a is None or not visible_to(scope, a.get("agent_id"), a.get("unit")):
330
+ # 404, not 403: outside your units, even existence is need-to-know
331
+ raise HTTPException(404, f"unknown approval {approval_id}")
332
+ return await _present(request, a)
333
+
334
+
335
+ @router.post("/{approval_id}/decision")
336
+ async def decide(request: Request, approval_id: str, body: Decision,
337
+ identity: dict | None = Depends(require_reviewer)):
338
+ reviewer = _reviewer_or_400(identity, body.reviewer)
339
+ tenant = await resolve_tenant(request)
340
+
341
+ # unit scopes visibility BEFORE approver_groups scope the decision
342
+ # (F24): deciding outside your units is refused as unknown, because
343
+ # even existence is need-to-know.
344
+ existing = await _ledger(request).approval(approval_id, tenant=tenant)
345
+ if existing is not None and not visible_to(
346
+ await activity_scope(request), existing.get("agent_id"),
347
+ existing.get("unit")):
348
+ raise HTTPException(404, f"unknown approval {approval_id}")
349
+
350
+ # enforce "who may approve": if the action pins approver_groups, the
351
+ # reviewer's IdP groups must intersect (admins bypass). A control that is
352
+ # recorded in the chain but silently unenforced would be the sharp edge
353
+ # for a product whose pitch is provable governance. The case's own
354
+ # snapshot binds as well as the live rule (B6): relaxing a policy never
355
+ # weakens a case that was proposed under the stricter one.
356
+ require_second = False
357
+ if existing is not None:
358
+ rule = await request.app.state.policy.policy_for(existing["tool"], tenant)
359
+ try:
360
+ require_second = case_controls(existing.get("policy"), rule,
361
+ identity)
362
+ except NotAnApprover as e:
363
+ raise HTTPException(403, str(e)) from None
364
+ if body.bulk and body.approved:
365
+ # a bulk approval is a glance, not a review: irreversible,
366
+ # dual-control and high/critical-risk actions are decided one
367
+ # by one, on their own case — enforced here, not in the client
368
+ why = _individual_only(existing.get("policy") or {}, require_second)
369
+ if why:
370
+ raise HTTPException(
371
+ 403, f"{approval_id} must be decided individually: {why}")
372
+ # dual control distinctness binds to the CREDENTIAL for issued
373
+ # tokens: the same token with a different typed name is still the
374
+ # same approver (the in-transaction check covers exact strings)
375
+ if (require_second and body.approved and identity
376
+ and identity.get("token_id")):
377
+ standing = existing.get("endorsed_by") or ""
378
+ if f"(token:{identity['token_id']}" in standing:
379
+ raise HTTPException(
380
+ 403, "the second approval must come from a different "
381
+ "credential, not a different name on the same token")
382
+
383
+ try:
384
+ a = await _ledger(request).decide(
385
+ approval_id, approved=body.approved, reviewer=reviewer,
386
+ reason=body.reason, reviewer_role=reviewer_role(identity),
387
+ require_second=require_second, tenant=tenant,
388
+ idem=await idempotency_of(request, body.model_dump()))
389
+ except UnknownApproval:
390
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
391
+ except AlreadyDecided as e:
392
+ raise HTTPException(409, f"already {e.status}") from None
393
+ except IdempotencyMismatch:
394
+ raise HTTPException(422, "Idempotency-Key was already used for a "
395
+ "different request") from None
396
+ except SecondReviewerRequired as e:
397
+ # 403, not 409: the approval is NOT decided, this reviewer just
398
+ # cannot be both halves of the pair
399
+ raise HTTPException(403, str(e)) from None
400
+ fault_point("after_decision_commit_before_reflect")
401
+ if a["status"] in ("granted", "denied"):
402
+ # an endorsement is not a decision: no card flip (and no callback —
403
+ # the outbox row is written by the ledger's decision hook, inside
404
+ # the decision's own transaction, so it survives a crash here)
405
+ for chat in getattr(request.app.state, "chat_routers", []):
406
+ chat.reflect(a)
407
+ # the decider is the human plane: the grant goes to the executor
408
+ return redact_grant(a)
409
+
410
+
411
+ def _individual_only(policy: dict, require_second: bool) -> str | None:
412
+ """Why a bulk approval is refused for this approval (None = allowed)."""
413
+ if policy.get("reversible") is False:
414
+ return "irreversible action"
415
+ if require_second or policy.get("require_second_approval"):
416
+ return "dual control"
417
+ if policy.get("risk") in ("high", "critical"):
418
+ return f"{policy['risk']} risk"
419
+ return None
420
+
421
+
422
+ @router.post("/{approval_id}/request-changes")
423
+ async def request_changes(request: Request, approval_id: str,
424
+ body: ChangesRequest,
425
+ identity: dict | None = Depends(require_reviewer)):
426
+ """Send the proposal back: neither approved nor refused. The agent
427
+ changes it and proposes again with `supersedes` set to this id."""
428
+ reviewer = _reviewer_or_400(identity, body.reviewer)
429
+ tenant = await resolve_tenant(request)
430
+ existing = await _ledger(request).approval(approval_id, tenant=tenant)
431
+ if existing is not None and not visible_to(
432
+ await activity_scope(request), existing.get("agent_id"),
433
+ existing.get("unit")):
434
+ raise HTTPException(404, f"unknown approval {approval_id}")
435
+ # sending a proposal back closes the case: it is a decision, bound by
436
+ # the same approver_groups as approve and deny (B11)
437
+ if existing is not None:
438
+ rule = await request.app.state.policy.policy_for(existing["tool"], tenant)
439
+ try:
440
+ case_controls(existing.get("policy"), rule, identity)
441
+ except NotAnApprover as e:
442
+ raise HTTPException(403, str(e)) from None
443
+ try:
444
+ a = await _ledger(request).request_changes(
445
+ approval_id, reviewer, body.reason,
446
+ reviewer_role=reviewer_role(identity), tenant=tenant,
447
+ idem=await idempotency_of(request, body.model_dump()))
448
+ except UnknownApproval:
449
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
450
+ except AlreadyDecided as e:
451
+ raise HTTPException(409, f"already {e.status}") from None
452
+ except IdempotencyMismatch:
453
+ raise HTTPException(422, "Idempotency-Key was already used for a "
454
+ "different request") from None
455
+ for chat in getattr(request.app.state, "chat_routers", []):
456
+ chat.reflect(a)
457
+ return a
458
+
459
+
460
+ @router.post("/{approval_id}/cancel", dependencies=[Depends(require_agent)])
461
+ async def cancel(request: Request, approval_id: str, body: Cancel):
462
+ """The proposing agent withdraws its pending proposal (agent plane).
463
+ A pinned token cancels only its own agent's proposals."""
464
+ tenant = await resolve_tenant(request)
465
+ agent_id, _unit = await resolve_agent(request)
466
+ try:
467
+ a = await _ledger(request).cancel(
468
+ approval_id, agent_id, body.reason, tenant=tenant,
469
+ pinned=(await agent_pin(request)) is not None,
470
+ idem=await idempotency_of(request, body.model_dump()))
471
+ except UnknownApproval:
472
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
473
+ except AlreadyDecided as e:
474
+ raise HTTPException(409, f"already {e.status}") from None
475
+ except ActionMismatch:
476
+ raise HTTPException(403, "a pinned token can only cancel its own "
477
+ "agent's proposals") from None
478
+ except IdempotencyMismatch:
479
+ raise HTTPException(422, "Idempotency-Key was already used for a "
480
+ "different request") from None
481
+ fault_point("after_write_commit_before_http")
482
+ for chat in getattr(request.app.state, "chat_routers", []):
483
+ chat.reflect(a)
484
+ return a
485
+
486
+
487
+ @router.post("/{approval_id}/redeem", dependencies=[Depends(require_agent)])
488
+ async def redeem(request: Request, approval_id: str, body: Redeem):
489
+ """The executor redeems the execution grant minted with the decision
490
+ — once. Agent plane: the gateway or tool proxy that holds the tool
491
+ credentials presents the grant's nonce right before dispatch. Under
492
+ the chain lock: 409 if the approval is not granted, carries no grant,
493
+ the nonce does not match, the grant expired, or it was already
494
+ redeemed. Success appends `grant_redeemed` to the approval's chain —
495
+ `executor` is the presenting credential's identity, written by the
496
+ server; `executor_instance` is whatever the caller called itself —
497
+ and returns that event's hash."""
498
+ tenant = await resolve_tenant(request)
499
+ agent_id, _unit = await resolve_agent(request)
500
+ # the attribution is the CREDENTIAL, resolved server-side (an issued
501
+ # token names its agent and its revocable id + label); the caller's
502
+ # name for itself is kept, but apart, as self-asserted context
503
+ executor = await agent_credential(request)
504
+ instance = (body.instance or body.executor).strip() or None
505
+ try:
506
+ a, event = await _ledger(request).redeem_grant(
507
+ approval_id, body.nonce, executor=executor, instance=instance,
508
+ tenant=tenant, only_agent=await agent_pin(request),
509
+ idem=await idempotency_of(request, body.model_dump()))
510
+ except UnknownApproval:
511
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
512
+ except ActionMismatch:
513
+ # 404, not 403: another agent's approvals are need-to-know
514
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
515
+ except GrantRefused as e:
516
+ raise HTTPException(409, str(e)) from None
517
+ except IdempotencyMismatch:
518
+ raise HTTPException(422, "Idempotency-Key was already used for a "
519
+ "different request") from None
520
+ fault_point("after_write_commit_before_http")
521
+ out = {"approval_id": approval_id, "status": a["status"],
522
+ "tool": a["tool"], "action_digest": a["grant"]["action_digest"],
523
+ "redeemed_ts_ms": event["ts_ms"], "redeemed_by": executor,
524
+ "event_hash": event["hash"]}
525
+ if instance:
526
+ out["redeemed_instance"] = instance
527
+ return out
528
+
529
+
530
+ @router.post("/{approval_id}/incident")
531
+ async def report_incident(request: Request, approval_id: str, body: Incident,
532
+ identity: dict | None = Depends(require_reviewer)):
533
+ """Record that this action's execution caused an incident: a chained
534
+ `incident_reported` event the rollout track record counts (and
535
+ demotes on), and a typed annotation on the case."""
536
+ reviewer = _reviewer_or_400(identity, body.reviewer)
537
+ tenant = await resolve_tenant(request)
538
+ existing = await _ledger(request).approval(approval_id, tenant=tenant)
539
+ if existing is not None and not visible_to(
540
+ await activity_scope(request), existing.get("agent_id"),
541
+ existing.get("unit")):
542
+ raise HTTPException(404, f"unknown approval {approval_id}")
543
+ try:
544
+ a = await _ledger(request).report_incident(
545
+ approval_id, reviewer, body.severity, body.summary, tenant=tenant,
546
+ idem=await idempotency_of(request, body.model_dump()))
547
+ except UnknownApproval:
548
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
549
+ except IdempotencyMismatch:
550
+ raise HTTPException(422, "Idempotency-Key was already used for a "
551
+ "different request") from None
552
+ fault_point("after_write_commit_before_http")
553
+ return a
554
+
555
+
556
+ @router.post("/{approval_id}/annotate")
557
+ async def annotate(request: Request, approval_id: str, body: Annotation,
558
+ identity: dict | None = Depends(require_reviewer)):
559
+ reviewer = _reviewer_or_400(identity, body.reviewer)
560
+ tenant = await resolve_tenant(request)
561
+ existing = await _ledger(request).approval(approval_id, tenant=tenant)
562
+ if existing is not None and not visible_to(
563
+ await activity_scope(request), existing.get("agent_id"),
564
+ existing.get("unit")):
565
+ raise HTTPException(404, f"unknown approval {approval_id}")
566
+ try:
567
+ a = await _ledger(request).annotate(
568
+ approval_id, reviewer, body.note, tenant=tenant,
569
+ idem=await idempotency_of(request, body.model_dump()))
570
+ except UnknownApproval:
571
+ raise HTTPException(404, f"unknown approval {approval_id}") from None
572
+ except IdempotencyMismatch:
573
+ raise HTTPException(422, "Idempotency-Key was already used for a "
574
+ "different request") from None
575
+ fault_point("after_write_commit_before_http")
576
+ return a