apsimo 1.3.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 (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. colony_sidecar/__init__.py +4 -0
@@ -0,0 +1,2105 @@
1
+ """Durable, transport-neutral authority for Colony action approvals.
2
+
3
+ Colony owns approval *state* but deliberately knows nothing about WhatsApp,
4
+ SMS, an operator deck, or any other decision transport. A host authenticates a
5
+ principal at the API boundary and supplies that server-derived principal to
6
+ this store. Caller prose such as ``approved_by`` is never authority.
7
+
8
+ The store provides two related primitives:
9
+
10
+ * An ``ApprovalRequest`` binds one queued job to an immutable action digest.
11
+ The first valid decision wins; expired or superseded requests cannot be
12
+ revived.
13
+ * A grant is an exact action scope with a configurable duration/use envelope.
14
+ Historical bounded behavior remains the default; an explicit deployment
15
+ setting can select standing duration and/or count without broadening scope.
16
+
17
+ SQLite transactions make decision and grant consumption atomic across Colony
18
+ workers. The database lives in ``COLONY_STATE_DIR`` and is safe to copy with
19
+ the rest of Colony state for rollback.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from contextlib import contextmanager
25
+ from dataclasses import dataclass
26
+ from datetime import datetime, timedelta, timezone
27
+ import hashlib
28
+ import json
29
+ import os
30
+ from pathlib import Path
31
+ import re
32
+ import sqlite3
33
+ from typing import Any, Dict, Iterator, Mapping, Optional
34
+ import uuid
35
+
36
+ from apsimo import get_state_dir
37
+
38
+
39
+ DB_FILENAME = "approval_authority.db"
40
+ DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60
41
+ DEFAULT_GRANT_TTL_SECONDS = 7 * 24 * 60 * 60
42
+ DEFAULT_GRANT_MAX_USES = 5
43
+ MAX_APPROVAL_REQUEST_TTL_SECONDS = 30 * 24 * 60 * 60
44
+ DEFAULT_GRANT_ENVELOPE_TTL_SECONDS = 30 * 24 * 60 * 60
45
+ DEFAULT_GRANT_ENVELOPE_MAX_USES = 100
46
+ # Backward-compatible names for callers that imported the historical static
47
+ # caps. Runtime grant validation uses GrantEnvelope instead.
48
+ MAX_GRANT_TTL_SECONDS = DEFAULT_GRANT_ENVELOPE_TTL_SECONDS
49
+ MAX_GRANT_USES = DEFAULT_GRANT_ENVELOPE_MAX_USES
50
+ GRANT_MAX_TTL_ENV = "COLONY_GRANT_MAX_TTL_SECONDS"
51
+ GRANT_MAX_USES_ENV = "COLONY_GRANT_MAX_USES"
52
+ GRANT_UNLIMITED_SENTINEL = "unlimited"
53
+ PRESENTATION_SCHEMA = "ColonyApprovalPresentationV1"
54
+ AUTHORIZATION_PROJECTION_SCHEMA = "ColonyApprovalAuthorizationProjectionV1"
55
+ TYPED_APPROVAL_SUBJECT_SCHEMA = "ColonyApprovalSubjectBindingV1"
56
+
57
+ _DECISION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{7,191}$")
58
+ _ACTION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,191}$")
59
+ _SECRET_ASSIGNMENT = re.compile(
60
+ r"(?i)\b(api[_-]?key|authorization|bearer|credential|password|secret|token)"
61
+ r"\s*[:=]\s*(?:bearer\s+)?[^\s,;]+"
62
+ )
63
+ _BEARER_VALUE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}")
64
+ _PEM_PRIVATE_KEY_BEGIN = re.compile(
65
+ r"-----BEGIN (?P<label>(?:[A-Z0-9]+ )*PRIVATE KEY)-----",
66
+ re.IGNORECASE,
67
+ )
68
+ _CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
69
+ _SUBJECT_KIND_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
70
+
71
+
72
+ class ApprovalAuthorityError(ValueError):
73
+ """A stable, API-safe approval error."""
74
+
75
+ def __init__(self, code: str, message: str) -> None:
76
+ super().__init__(message)
77
+ self.code = code
78
+ self.message = message
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class GrantEnvelope:
83
+ """Effective duration/count limits for newly issued exact-scope grants.
84
+
85
+ ``None`` is the normalized form of the explicit ``unlimited`` sentinel.
86
+ It never applies to pending approval requests, which retain their fixed
87
+ request lifetime.
88
+ """
89
+
90
+ max_ttl_seconds: Optional[int]
91
+ max_uses: Optional[int]
92
+
93
+ @property
94
+ def standing_dimensions(self) -> tuple[str, ...]:
95
+ dimensions = []
96
+ if self.max_ttl_seconds is None:
97
+ dimensions.append(GRANT_MAX_TTL_ENV)
98
+ if self.max_uses is None:
99
+ dimensions.append(GRANT_MAX_USES_ENV)
100
+ return tuple(dimensions)
101
+
102
+
103
+ def _grant_envelope_value(
104
+ name: str,
105
+ *,
106
+ default: int,
107
+ minimum: int,
108
+ ) -> Optional[int]:
109
+ raw = os.environ.get(name)
110
+ if raw is None:
111
+ return default
112
+ value = raw.strip()
113
+ if value.lower() == GRANT_UNLIMITED_SENTINEL:
114
+ return None
115
+ if not re.fullmatch(r"[0-9]+", value):
116
+ raise RuntimeError(
117
+ f"{name} must be a positive integer or "
118
+ f"{GRANT_UNLIMITED_SENTINEL!r}, got {raw!r}"
119
+ )
120
+ parsed = int(value)
121
+ if parsed < minimum:
122
+ raise RuntimeError(
123
+ f"{name} must be at least {minimum} or "
124
+ f"{GRANT_UNLIMITED_SENTINEL!r}, got {raw!r}"
125
+ )
126
+ return parsed
127
+
128
+
129
+ def resolve_grant_envelope() -> GrantEnvelope:
130
+ """Strictly resolve the grant envelope from the loaded environment."""
131
+
132
+ return GrantEnvelope(
133
+ max_ttl_seconds=_grant_envelope_value(
134
+ GRANT_MAX_TTL_ENV,
135
+ default=DEFAULT_GRANT_ENVELOPE_TTL_SECONDS,
136
+ minimum=60,
137
+ ),
138
+ max_uses=_grant_envelope_value(
139
+ GRANT_MAX_USES_ENV,
140
+ default=DEFAULT_GRANT_ENVELOPE_MAX_USES,
141
+ minimum=1,
142
+ ),
143
+ )
144
+
145
+
146
+ def grant_envelope_posture(
147
+ envelope: Optional[GrantEnvelope] = None,
148
+ ) -> Dict[str, Any]:
149
+ """Return a typed, audit-safe view of the effective grant envelope."""
150
+
151
+ resolved = envelope or resolve_grant_envelope()
152
+ return {
153
+ "max_ttl_seconds": resolved.max_ttl_seconds,
154
+ "max_ttl_state": (
155
+ "unbounded" if resolved.max_ttl_seconds is None else "bounded"
156
+ ),
157
+ "max_uses": resolved.max_uses,
158
+ "max_uses_state": (
159
+ "unbounded" if resolved.max_uses is None else "bounded"
160
+ ),
161
+ "standing": bool(resolved.standing_dimensions),
162
+ "sentinel": GRANT_UNLIMITED_SENTINEL,
163
+ }
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class ActionBinding:
168
+ """Server-derived immutable identity and reusable exact scope."""
169
+
170
+ action_digest: str
171
+ scope: Dict[str, Any]
172
+ scope_digest: str
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class ApprovalSubjectBinding:
177
+ """Server-derived identity for a non-queue approval subject.
178
+
179
+ The authority store remains domain-neutral. Domain services opt in by
180
+ attaching an immutable kind/id/revision/action tuple when the request is
181
+ born; an existing request can never be relabelled as a typed subject.
182
+ """
183
+
184
+ kind: str
185
+ subject_id: str
186
+ revision: str
187
+ action: str
188
+
189
+ def payload(self) -> Dict[str, str]:
190
+ return _validated_subject({
191
+ "kind": self.kind,
192
+ "subject_id": self.subject_id,
193
+ "revision": self.revision,
194
+ "action": self.action,
195
+ })
196
+
197
+
198
+ def _utcnow() -> datetime:
199
+ return datetime.now(timezone.utc)
200
+
201
+
202
+ def _as_utc(value: Optional[datetime]) -> datetime:
203
+ result = value or _utcnow()
204
+ if result.tzinfo is None:
205
+ result = result.replace(tzinfo=timezone.utc)
206
+ return result.astimezone(timezone.utc)
207
+
208
+
209
+ def _iso(value: datetime) -> str:
210
+ return _as_utc(value).isoformat()
211
+
212
+
213
+ def _canonical(value: Any) -> str:
214
+ """Canonical JSON used only for digests and bounded public metadata."""
215
+
216
+ def default(item: Any) -> str:
217
+ if isinstance(item, datetime):
218
+ return _iso(item)
219
+ return str(item)
220
+
221
+ return json.dumps(
222
+ value,
223
+ sort_keys=True,
224
+ separators=(",", ":"),
225
+ ensure_ascii=False,
226
+ default=default,
227
+ )
228
+
229
+
230
+ def _digest(value: Any) -> str:
231
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
232
+
233
+
234
+ def _validated_subject(value: Mapping[str, Any]) -> Dict[str, str]:
235
+ if not isinstance(value, Mapping) or set(value) != {
236
+ "kind", "subject_id", "revision", "action",
237
+ }:
238
+ raise ApprovalAuthorityError(
239
+ "invalid_subject_binding",
240
+ "approval subject binding fields are invalid",
241
+ )
242
+ kind = str(value.get("kind") or "").strip()
243
+ subject_id = str(value.get("subject_id") or "").strip()
244
+ revision = str(value.get("revision") or "").strip()
245
+ action = str(value.get("action") or "").strip().lower()
246
+ if not _SUBJECT_KIND_RE.fullmatch(kind):
247
+ raise ApprovalAuthorityError(
248
+ "invalid_subject_binding", "approval subject kind is invalid",
249
+ )
250
+ for field, text in (
251
+ ("subject_id", subject_id), ("revision", revision),
252
+ ):
253
+ if (
254
+ not text or len(text) > 256 or _CONTROL.search(text)
255
+ or not _ACTION_RE.fullmatch(text)
256
+ ):
257
+ raise ApprovalAuthorityError(
258
+ "invalid_subject_binding",
259
+ f"approval subject {field} is invalid",
260
+ )
261
+ if not action or not _ACTION_RE.fullmatch(action):
262
+ raise ApprovalAuthorityError(
263
+ "invalid_subject_binding", "approval subject action is invalid",
264
+ )
265
+ return {
266
+ "kind": kind,
267
+ "subject_id": subject_id,
268
+ "revision": revision,
269
+ "action": action,
270
+ }
271
+
272
+
273
+ def approval_subject_digest(
274
+ subject: Mapping[str, Any],
275
+ *,
276
+ action_digest: str,
277
+ scope_digest: str,
278
+ presentation_digest: str,
279
+ ) -> str:
280
+ """Bind a typed subject to the exact action and owner presentation."""
281
+
282
+ return _digest({
283
+ "version": 1,
284
+ "schema": TYPED_APPROVAL_SUBJECT_SCHEMA,
285
+ "subject": _validated_subject(subject),
286
+ "action_digest": str(action_digest),
287
+ "scope_digest": str(scope_digest),
288
+ "presentation_digest": str(presentation_digest),
289
+ })
290
+
291
+
292
+ def _nested_value(payload: Mapping[str, Any], key: str) -> Any:
293
+ if key in payload:
294
+ return payload.get(key)
295
+ context = payload.get("context")
296
+ if isinstance(context, Mapping):
297
+ if key in context:
298
+ return context.get(key)
299
+ upper = key.upper()
300
+ if upper in context:
301
+ return context.get(upper)
302
+ return None
303
+
304
+
305
+ def _redact_pem_private_keys(value: str) -> str:
306
+ """Remove bounded PEM private-key blocks without scanning them greedily."""
307
+
308
+ text = value
309
+ cursor = 0
310
+ for _ in range(32):
311
+ match = _PEM_PRIVATE_KEY_BEGIN.search(text, cursor)
312
+ if match is None:
313
+ return text
314
+ end_pattern = re.compile(
315
+ re.escape(f"-----END {match.group('label')}-----"),
316
+ re.IGNORECASE,
317
+ )
318
+ end = end_pattern.search(text, match.end())
319
+ end_offset = end.end() if end is not None else len(text)
320
+ text = (
321
+ text[:match.start()]
322
+ + "[REDACTED PRIVATE KEY]"
323
+ + text[end_offset:]
324
+ )
325
+ cursor = match.start() + len("[REDACTED PRIVATE KEY]")
326
+ if end is None:
327
+ return text
328
+ # An adversarial description with more than 32 PEM blocks is not useful
329
+ # owner-facing content. Preserve its safe prefix and redact the remainder.
330
+ overflow = _PEM_PRIVATE_KEY_BEGIN.search(text, cursor)
331
+ if overflow is not None:
332
+ text = text[:overflow.start()] + "[REDACTED PRIVATE KEY MATERIAL]"
333
+ return text
334
+
335
+
336
+ def _safe_display_text(value: Any, *, maximum: int, fallback: str = "") -> str:
337
+ """Return bounded single-line owner display text with common secrets removed."""
338
+
339
+ text = _redact_pem_private_keys(str(value or ""))
340
+ text = _CONTROL.sub(" ", text)
341
+ text = _SECRET_ASSIGNMENT.sub(
342
+ lambda match: f"{match.group(1)}=[REDACTED]", text,
343
+ )
344
+ text = _BEARER_VALUE.sub("Bearer [REDACTED]", text)
345
+ text = " ".join(text.split())
346
+ if not text:
347
+ text = fallback
348
+ return text[:maximum]
349
+
350
+
351
+ def approval_binding_digest(
352
+ *,
353
+ job_id: str,
354
+ job_type: str,
355
+ action_digest: str,
356
+ scope_digest: str,
357
+ ) -> str:
358
+ return _digest({
359
+ "version": 1,
360
+ "job_id": str(job_id),
361
+ "job_type": str(job_type),
362
+ "action_digest": str(action_digest),
363
+ "scope_digest": str(scope_digest),
364
+ })
365
+
366
+
367
+ def approval_presentation_digest(presentation: Mapping[str, Any]) -> str:
368
+ return _digest(_validated_presentation(presentation))
369
+
370
+
371
+ def _validated_presentation(value: Mapping[str, Any]) -> Dict[str, Any]:
372
+ if not isinstance(value, Mapping):
373
+ raise ApprovalAuthorityError(
374
+ "invalid_presentation", "approval presentation must be an object"
375
+ )
376
+ expected = {
377
+ "schema", "version", "summary", "action_name", "risk", "effect",
378
+ "target", "capabilities", "deadline", "reversibility", "constraints",
379
+ }
380
+ if set(value) != expected:
381
+ raise ApprovalAuthorityError(
382
+ "invalid_presentation", "approval presentation fields are invalid"
383
+ )
384
+ if value.get("schema") != PRESENTATION_SCHEMA or value.get("version") != 1:
385
+ raise ApprovalAuthorityError(
386
+ "invalid_presentation", "approval presentation schema is invalid"
387
+ )
388
+ capabilities = value.get("capabilities")
389
+ constraints = value.get("constraints")
390
+ if (
391
+ not isinstance(capabilities, list)
392
+ or len(capabilities) > 32
393
+ or not all(
394
+ isinstance(item, str) and 0 < len(item) <= 128
395
+ for item in capabilities
396
+ )
397
+ or capabilities != sorted(set(capabilities))
398
+ or not isinstance(constraints, Mapping)
399
+ or len(constraints) > 32
400
+ ):
401
+ raise ApprovalAuthorityError(
402
+ "invalid_presentation", "approval presentation bounds are invalid"
403
+ )
404
+ normalized_constraints: Dict[str, Optional[str]] = {}
405
+ for key, digest in constraints.items():
406
+ if (
407
+ not isinstance(key, str)
408
+ or not key
409
+ or len(key) > 128
410
+ or (
411
+ digest is not None
412
+ and (
413
+ not isinstance(digest, str)
414
+ or not re.fullmatch(r"[0-9a-f]{64}", digest)
415
+ )
416
+ )
417
+ ):
418
+ raise ApprovalAuthorityError(
419
+ "invalid_presentation", "approval presentation constraint is invalid"
420
+ )
421
+ normalized_constraints[key] = digest
422
+ result = {
423
+ "schema": PRESENTATION_SCHEMA,
424
+ "version": 1,
425
+ "summary": _safe_display_text(value.get("summary"), maximum=1200),
426
+ "action_name": _safe_display_text(
427
+ value.get("action_name"), maximum=192,
428
+ ),
429
+ "risk": _safe_display_text(value.get("risk"), maximum=64),
430
+ "effect": _safe_display_text(value.get("effect"), maximum=32),
431
+ "target": _safe_display_text(value.get("target"), maximum=512),
432
+ "capabilities": list(capabilities),
433
+ "deadline": _safe_display_text(value.get("deadline"), maximum=64),
434
+ "reversibility": _safe_display_text(
435
+ value.get("reversibility"), maximum=256, fallback="unspecified",
436
+ ),
437
+ "constraints": dict(sorted(normalized_constraints.items())),
438
+ }
439
+ if not result["summary"] or not result["action_name"] or not result["risk"]:
440
+ raise ApprovalAuthorityError(
441
+ "invalid_presentation", "approval presentation is incomplete"
442
+ )
443
+ if len(_canonical(result).encode("utf-8")) > 16 * 1024:
444
+ raise ApprovalAuthorityError(
445
+ "invalid_presentation", "approval presentation is too large"
446
+ )
447
+ return result
448
+
449
+
450
+ def build_approval_presentation(
451
+ *,
452
+ job_id: str,
453
+ job_type: str,
454
+ payload: Mapping[str, Any],
455
+ deadline: Any = None,
456
+ ) -> Dict[str, Any]:
457
+ """Build the transport-neutral, redacted view an owner may approve.
458
+
459
+ Raw payloads and context references never enter this projection. Exact
460
+ action constraints are represented only by their server-derived digests.
461
+ """
462
+
463
+ binding = build_action_binding(
464
+ job_id=job_id,
465
+ job_type=job_type,
466
+ payload=payload,
467
+ )
468
+ action_name = binding.scope["action_name"]
469
+ if payload.get("schema") == "ApprovalRelayCanaryV1":
470
+ from apsimo.task_queue.approval_relay_canary import (
471
+ ACTION_HINT as CANARY_ACTION_HINT,
472
+ job_id_for_digest,
473
+ payload_for_digest,
474
+ )
475
+
476
+ digest = payload.get("idempotency_digest")
477
+ exact_canary = bool(
478
+ isinstance(digest, str)
479
+ and action_name == CANARY_ACTION_HINT
480
+ and str(job_id) == job_id_for_digest(digest)
481
+ and dict(payload) == payload_for_digest(digest)
482
+ )
483
+ if not exact_canary:
484
+ raise ApprovalAuthorityError(
485
+ "invalid_action_scope",
486
+ "approval relay canary presentation identity drifted",
487
+ )
488
+ return _validated_presentation({
489
+ "schema": PRESENTATION_SCHEMA,
490
+ "version": 1,
491
+ "summary": (
492
+ "Calibrate the owner approval relay. Either choice records "
493
+ "the decision and permanently cancels this no-effect job."
494
+ ),
495
+ "action_name": action_name,
496
+ "risk": "calibration",
497
+ "effect": "none",
498
+ "target": "internal approval relay",
499
+ "capabilities": [],
500
+ "deadline": _safe_display_text(
501
+ deadline, maximum=64, fallback="unspecified",
502
+ ),
503
+ "reversibility": "no execution; terminal cancellation only",
504
+ "constraints": binding.scope.get("constraints") or {},
505
+ })
506
+ risk = str(binding.scope.get("risk") or "unknown")
507
+ effect = "unknown"
508
+ if risk in {"mutation", "mutating", "destructive"}:
509
+ effect = "mutation"
510
+ elif risk in {"disclosure", "outbound"}:
511
+ effect = "disclosure"
512
+ elif risk in {"read_only", "internal"}:
513
+ effect = "none"
514
+
515
+ summary = (
516
+ payload.get("objective")
517
+ or payload.get("description")
518
+ or payload.get("request")
519
+ or action_name
520
+ )
521
+ target = payload.get("recipient_scope")
522
+ if target in (None, ""):
523
+ try:
524
+ from apsimo.initiatives.action_registry import get_action
525
+
526
+ spec = get_action(action_name)
527
+ target = _nested_value(payload, spec.target_param) if spec and spec.target_param else None
528
+ except Exception:
529
+ target = None
530
+ if target in (None, ""):
531
+ for name in ("recipient", "target", "to"):
532
+ target = _nested_value(payload, name)
533
+ if target not in (None, ""):
534
+ break
535
+
536
+ raw_capabilities = payload.get("capability_allowlist") or ()
537
+ capabilities = sorted({
538
+ _safe_display_text(item, maximum=128)
539
+ for item in raw_capabilities
540
+ if _safe_display_text(item, maximum=128)
541
+ })[:32]
542
+ reversibility = (
543
+ payload.get("reversibility")
544
+ or ("reversible" if payload.get("reversible") is True else None)
545
+ or ("irreversible" if payload.get("reversible") is False else None)
546
+ or "unspecified"
547
+ )
548
+ return _validated_presentation({
549
+ "schema": PRESENTATION_SCHEMA,
550
+ "version": 1,
551
+ "summary": _safe_display_text(summary, maximum=1200, fallback=action_name),
552
+ "action_name": action_name,
553
+ "risk": risk,
554
+ "effect": effect,
555
+ "target": _safe_display_text(target, maximum=512, fallback="unspecified"),
556
+ "capabilities": capabilities,
557
+ "deadline": _safe_display_text(
558
+ deadline or payload.get("deadline"), maximum=64, fallback="unspecified",
559
+ ),
560
+ "reversibility": reversibility,
561
+ "constraints": binding.scope.get("constraints") or {},
562
+ })
563
+
564
+
565
+ def _request_ttl_for_deadline(
566
+ deadline: Any,
567
+ *,
568
+ now: Optional[datetime] = None,
569
+ ) -> int:
570
+ """Return a request lifetime that never outlives the queue deadline."""
571
+
572
+ observed = _as_utc(now)
573
+ if deadline in (None, ""):
574
+ return DEFAULT_REQUEST_TTL_SECONDS
575
+ if isinstance(deadline, datetime):
576
+ parsed = _as_utc(deadline)
577
+ else:
578
+ try:
579
+ parsed = datetime.fromisoformat(str(deadline).replace("Z", "+00:00"))
580
+ except (TypeError, ValueError):
581
+ return DEFAULT_REQUEST_TTL_SECONDS
582
+ parsed = _as_utc(parsed)
583
+ remaining = int((parsed - observed).total_seconds())
584
+ if remaining < 60:
585
+ return 0
586
+ return min(DEFAULT_REQUEST_TTL_SECONDS, remaining)
587
+
588
+
589
+ def canonical_approval_timeout_seconds() -> int:
590
+ """One timeout used by request creation and blocked-job governance."""
591
+
592
+ raw = os.environ.get("COLONY_APPROVAL_TIMEOUT_HOURS")
593
+ if raw in (None, ""):
594
+ return DEFAULT_REQUEST_TTL_SECONDS
595
+ try:
596
+ seconds = int(float(raw) * 60 * 60)
597
+ except (TypeError, ValueError):
598
+ return DEFAULT_REQUEST_TTL_SECONDS
599
+ return max(60, min(seconds, MAX_APPROVAL_REQUEST_TTL_SECONDS))
600
+
601
+
602
+ def prepare_action_approval(
603
+ store: "ApprovalAuthorityStore",
604
+ *,
605
+ job_id: str,
606
+ job_type: str,
607
+ payload: Mapping[str, Any],
608
+ deadline: Any = None,
609
+ approval_started_at: Optional[datetime] = None,
610
+ now: Optional[datetime] = None,
611
+ ) -> Dict[str, Any]:
612
+ """Resolve one server-owned effect gate without any transport coupling.
613
+
614
+ The caller must keep the queue row unclaimable until it durably applies the
615
+ returned state/tags. Repeating this function reuses the exact request or
616
+ grant-use record, which closes restart and partial-transition gaps.
617
+ """
618
+
619
+ binding = build_action_binding(
620
+ job_id=job_id,
621
+ job_type=job_type,
622
+ payload=payload,
623
+ )
624
+ presentation = build_approval_presentation(
625
+ job_id=job_id,
626
+ job_type=job_type,
627
+ payload=payload,
628
+ deadline=deadline,
629
+ )
630
+ presentation_digest = _digest(presentation)
631
+ common = {
632
+ "action_digest": binding.action_digest,
633
+ "approval_scope_digest": binding.scope_digest,
634
+ "approval_presentation_digest": presentation_digest,
635
+ }
636
+
637
+ def request_result(request: Mapping[str, Any]) -> Dict[str, Any]:
638
+ request_tags = {
639
+ **common,
640
+ "approval_request_id": request["request_id"],
641
+ "approval_binding_digest": request["binding_digest"],
642
+ "approval_request_digest": request["request_digest"],
643
+ "approval_expires_at": request["expires_at"],
644
+ }
645
+ if request["status"] == "approved" and request.get("decision") == "approve":
646
+ state = "authorized_direct"
647
+ request_tags.update({
648
+ "approval_provenance": "server_direct_decision",
649
+ "approval_decision_id": request["decision_id"],
650
+ "approved_by": request["decided_by"],
651
+ "approved_at": request["decided_at"],
652
+ })
653
+ elif request["status"] == "rejected":
654
+ state = "rejected"
655
+ request_tags.update({
656
+ "approval_provenance": "server_direct_decision",
657
+ "approval_decision_id": request["decision_id"],
658
+ "rejected_by": request["decided_by"],
659
+ "rejected_at": request["decided_at"],
660
+ })
661
+ elif request["status"] in {"expired", "superseded"}:
662
+ state = request["status"]
663
+ else:
664
+ state = "pending"
665
+ return {
666
+ "state": state,
667
+ "binding": binding,
668
+ "presentation": presentation,
669
+ "request": dict(request),
670
+ "grant_use": None,
671
+ "tags": request_tags,
672
+ }
673
+
674
+ observed = _as_utc(now)
675
+ effective_deadline = deadline
676
+ if approval_started_at is not None:
677
+ approval_deadline = _as_utc(approval_started_at) + timedelta(
678
+ seconds=canonical_approval_timeout_seconds()
679
+ )
680
+ if effective_deadline in (None, ""):
681
+ effective_deadline = approval_deadline
682
+ else:
683
+ try:
684
+ parsed_deadline = (
685
+ _as_utc(effective_deadline)
686
+ if isinstance(effective_deadline, datetime)
687
+ else _as_utc(datetime.fromisoformat(
688
+ str(effective_deadline).replace("Z", "+00:00")
689
+ ))
690
+ )
691
+ except (TypeError, ValueError):
692
+ parsed_deadline = approval_deadline
693
+ effective_deadline = min(parsed_deadline, approval_deadline)
694
+ ttl_seconds = _request_ttl_for_deadline(effective_deadline, now=observed)
695
+ resolved = store.resolve_action_gate(
696
+ job_id=job_id,
697
+ binding=binding,
698
+ operation_id=job_id,
699
+ ttl_seconds=ttl_seconds,
700
+ presentation=presentation,
701
+ now=observed,
702
+ )
703
+ if resolved["kind"] == "request":
704
+ # Reconciliation preserves the original exact request and expiry.
705
+ # A durable request (including a rejection or projected expiry) always
706
+ # wins over a reusable grant for the same operation.
707
+ return request_result(resolved["request"])
708
+ if resolved["kind"] == "deadline_expired":
709
+ return {
710
+ "state": "expired",
711
+ "binding": binding,
712
+ "presentation": presentation,
713
+ "request": None,
714
+ "grant_use": None,
715
+ "tags": {
716
+ **common,
717
+ "approval_provenance": "server_deadline_expired",
718
+ },
719
+ }
720
+ if resolved["kind"] != "grant":
721
+ raise ApprovalAuthorityError(
722
+ "approval_resolution_invalid",
723
+ "approval gate returned an invalid authority state",
724
+ )
725
+ use = resolved["grant_use"]
726
+ if use is None:
727
+ raise ApprovalAuthorityError(
728
+ "grant_provenance_missing",
729
+ "bounded grant consumption has no durable use record",
730
+ )
731
+ ttl_unbounded = bool(use.get("grant_ttl_unbounded"))
732
+ uses_unbounded = bool(use.get("grant_uses_unbounded"))
733
+ return {
734
+ "state": "authorized_grant",
735
+ "binding": binding,
736
+ "presentation": presentation,
737
+ "request": None,
738
+ "grant_use": use,
739
+ "tags": {
740
+ **common,
741
+ "auto_approved_by_policy": "bounded_grant",
742
+ "approval_provenance": "server_bounded_grant",
743
+ "bounded_grant_id": use["grant_id"],
744
+ "approval_source_request_id": use["source_request_id"],
745
+ "approval_decision_id": use["decision_id"],
746
+ "approved_by": use["granted_by"],
747
+ "approved_at": use["consumed_at"],
748
+ "bounded_grant_expires_at": (
749
+ GRANT_UNLIMITED_SENTINEL
750
+ if ttl_unbounded else use["grant_expires_at"]
751
+ ),
752
+ "bounded_grant_ttl_state": (
753
+ "unbounded" if ttl_unbounded else "bounded"
754
+ ),
755
+ "bounded_grant_uses_state": (
756
+ "unbounded" if uses_unbounded else "bounded"
757
+ ),
758
+ },
759
+ }
760
+
761
+
762
+ def build_action_binding(
763
+ *,
764
+ job_id: str,
765
+ job_type: str,
766
+ payload: Mapping[str, Any],
767
+ ) -> ActionBinding:
768
+ """Derive an immutable job digest and an exact reusable grant scope.
769
+
770
+ Scope is based solely on the registered action contract and hashes of its
771
+ required values. Raw recipient, message, path, and repository values are
772
+ therefore not copied into the authority database. Missing constraints are
773
+ represented explicitly, so a later request cannot broaden an approval by
774
+ dropping a field.
775
+ """
776
+
777
+ action_name = str(payload.get("action_hint") or "").strip()
778
+ if not action_name or not _ACTION_RE.fullmatch(action_name):
779
+ raise ApprovalAuthorityError(
780
+ "invalid_action_scope", "job has no valid registered action name"
781
+ )
782
+ normalized_job_type = str(job_type or "").strip()
783
+ if not normalized_job_type:
784
+ raise ApprovalAuthorityError("invalid_action_scope", "job_type is required")
785
+
786
+ required: list[str] = []
787
+ risk = str(payload.get("risk") or payload.get("risk_class") or "unknown").strip()
788
+ try:
789
+ from apsimo.initiatives.action_registry import get_action
790
+
791
+ spec = get_action(action_name)
792
+ if spec is not None:
793
+ required = list(spec.required_params)
794
+ if spec.target_param and spec.target_param not in required:
795
+ required.append(spec.target_param)
796
+ risk = spec.risk.value
797
+ except Exception:
798
+ # WorkOrder and extension actions can be registered by the host rather
799
+ # than Colony's built-in registry. Their exact action/risk/type tuple is
800
+ # still bounded; the complete payload remains bound by action_digest.
801
+ pass
802
+
803
+ if payload.get("schema") == "WorkOrderV1" and payload.get("version") == 1:
804
+ # WorkOrder actions are host extension actions rather than built-in
805
+ # registry entries. Their reusable scope must still bind the project,
806
+ # recipient, source and exact capability ceiling. Step/job identity is
807
+ # intentionally excluded so a bounded multi-use grant can authorize a
808
+ # later step only inside that same scope.
809
+ required = [
810
+ "source", "project_id", "recipient_scope", "capability_allowlist",
811
+ ]
812
+ risk = str(payload.get("risk_class") or risk or "unknown").strip()
813
+
814
+ constraints: Dict[str, Optional[str]] = {}
815
+ for name in sorted(set(required)):
816
+ raw = _nested_value(payload, name)
817
+ constraints[name] = _digest(raw) if raw is not None else None
818
+
819
+ scope = {
820
+ "version": 1,
821
+ "job_type": normalized_job_type,
822
+ "action_name": action_name,
823
+ "risk": risk or "unknown",
824
+ "constraints": constraints,
825
+ }
826
+ snapshot = {
827
+ "version": 1,
828
+ "job_id": str(job_id),
829
+ "job_type": normalized_job_type,
830
+ "payload": dict(payload),
831
+ }
832
+ return ActionBinding(
833
+ action_digest=_digest(snapshot),
834
+ scope=scope,
835
+ scope_digest=_digest(scope),
836
+ )
837
+
838
+
839
+ def legacy_action_binding(
840
+ action_name: str,
841
+ *,
842
+ operation_id: str,
843
+ job_type: str = "legacy_action",
844
+ risk: str = "mutating",
845
+ ) -> ActionBinding:
846
+ """Build a bounded scope for older non-queue action gates."""
847
+
848
+ payload = {"action_hint": action_name, "risk": risk}
849
+ return build_action_binding(
850
+ job_id=operation_id,
851
+ job_type=job_type,
852
+ payload=payload,
853
+ )
854
+
855
+
856
+ class ApprovalAuthorityStore:
857
+ """Transactional approval and bounded-grant ledger."""
858
+
859
+ def __init__(
860
+ self,
861
+ db_path: str | os.PathLike[str] | None = None,
862
+ *,
863
+ grant_envelope: Optional[GrantEnvelope] = None,
864
+ ) -> None:
865
+ self.grant_envelope = grant_envelope or resolve_grant_envelope()
866
+ self.path = Path(db_path) if db_path is not None else get_state_dir() / DB_FILENAME
867
+ self.path.parent.mkdir(parents=True, exist_ok=True)
868
+ if not self.path.exists():
869
+ self.path.touch(mode=0o600)
870
+ else:
871
+ os.chmod(self.path, 0o600)
872
+ self._initialize()
873
+
874
+ @contextmanager
875
+ def _connect(self) -> Iterator[sqlite3.Connection]:
876
+ conn = sqlite3.connect(str(self.path), timeout=30.0)
877
+ try:
878
+ conn.row_factory = sqlite3.Row
879
+ conn.execute("PRAGMA foreign_keys=ON")
880
+ conn.execute("PRAGMA busy_timeout=30000")
881
+ # sqlite3.Connection.__exit__ commits or rolls back but does not
882
+ # close. Preserve its transaction semantics and own the resource
883
+ # lifetime explicitly so repeated approval projections cannot
884
+ # exhaust the process descriptor limit.
885
+ with conn:
886
+ yield conn
887
+ finally:
888
+ conn.close()
889
+
890
+ def _initialize(self) -> None:
891
+ with self._connect() as conn:
892
+ conn.execute("PRAGMA journal_mode=WAL")
893
+ conn.executescript(
894
+ """
895
+ CREATE TABLE IF NOT EXISTS approval_requests (
896
+ request_id TEXT PRIMARY KEY,
897
+ job_id TEXT NOT NULL,
898
+ action_digest TEXT NOT NULL,
899
+ scope_json TEXT NOT NULL,
900
+ scope_digest TEXT NOT NULL,
901
+ status TEXT NOT NULL,
902
+ created_at TEXT NOT NULL,
903
+ expires_at TEXT NOT NULL,
904
+ superseded_by TEXT,
905
+ decision TEXT,
906
+ decision_id TEXT UNIQUE,
907
+ decided_at TEXT,
908
+ decided_by TEXT,
909
+ authority_evidence TEXT,
910
+ grant_id TEXT,
911
+ presentation_json TEXT,
912
+ presentation_digest TEXT,
913
+ subject_kind TEXT,
914
+ subject_id TEXT,
915
+ subject_revision TEXT,
916
+ subject_action TEXT,
917
+ subject_digest TEXT
918
+ );
919
+ CREATE INDEX IF NOT EXISTS approval_requests_job_idx
920
+ ON approval_requests(job_id, created_at DESC);
921
+ CREATE INDEX IF NOT EXISTS approval_requests_status_idx
922
+ ON approval_requests(status, expires_at);
923
+
924
+ CREATE TABLE IF NOT EXISTS bounded_grants (
925
+ grant_id TEXT PRIMARY KEY,
926
+ source_request_id TEXT NOT NULL,
927
+ scope_json TEXT NOT NULL,
928
+ scope_digest TEXT NOT NULL,
929
+ status TEXT NOT NULL,
930
+ created_at TEXT NOT NULL,
931
+ expires_at TEXT NOT NULL,
932
+ ttl_unbounded INTEGER NOT NULL DEFAULT 0
933
+ CHECK (ttl_unbounded IN (0, 1)),
934
+ max_uses INTEGER NOT NULL,
935
+ uses_unbounded INTEGER NOT NULL DEFAULT 0
936
+ CHECK (uses_unbounded IN (0, 1)),
937
+ uses INTEGER NOT NULL DEFAULT 0,
938
+ granted_by TEXT NOT NULL,
939
+ decision_id TEXT NOT NULL,
940
+ revoked_at TEXT,
941
+ FOREIGN KEY(source_request_id)
942
+ REFERENCES approval_requests(request_id)
943
+ );
944
+ CREATE INDEX IF NOT EXISTS bounded_grants_scope_idx
945
+ ON bounded_grants(scope_digest, status, expires_at);
946
+
947
+ CREATE TABLE IF NOT EXISTS bounded_grant_uses (
948
+ action_digest TEXT PRIMARY KEY,
949
+ grant_id TEXT NOT NULL,
950
+ operation_id TEXT NOT NULL,
951
+ consumed_at TEXT NOT NULL,
952
+ FOREIGN KEY(grant_id) REFERENCES bounded_grants(grant_id)
953
+ );
954
+ """
955
+ )
956
+ columns = {
957
+ row["name"]
958
+ for row in conn.execute(
959
+ "PRAGMA table_info(approval_requests)"
960
+ ).fetchall()
961
+ }
962
+ if "presentation_json" not in columns:
963
+ conn.execute(
964
+ "ALTER TABLE approval_requests ADD COLUMN presentation_json TEXT"
965
+ )
966
+ if "presentation_digest" not in columns:
967
+ conn.execute(
968
+ "ALTER TABLE approval_requests ADD COLUMN presentation_digest TEXT"
969
+ )
970
+ subject_columns = {
971
+ "subject_kind": "TEXT",
972
+ "subject_id": "TEXT",
973
+ "subject_revision": "TEXT",
974
+ "subject_action": "TEXT",
975
+ "subject_digest": "TEXT",
976
+ }
977
+ for name, column_type in subject_columns.items():
978
+ if name not in columns:
979
+ conn.execute(
980
+ f"ALTER TABLE approval_requests ADD COLUMN {name} "
981
+ f"{column_type}"
982
+ )
983
+ grant_columns = {
984
+ row["name"]
985
+ for row in conn.execute(
986
+ "PRAGMA table_info(bounded_grants)"
987
+ ).fetchall()
988
+ }
989
+ if "ttl_unbounded" not in grant_columns:
990
+ conn.execute(
991
+ "ALTER TABLE bounded_grants ADD COLUMN ttl_unbounded "
992
+ "INTEGER NOT NULL DEFAULT 0 "
993
+ "CHECK (ttl_unbounded IN (0, 1))"
994
+ )
995
+ if "uses_unbounded" not in grant_columns:
996
+ conn.execute(
997
+ "ALTER TABLE bounded_grants ADD COLUMN uses_unbounded "
998
+ "INTEGER NOT NULL DEFAULT 0 "
999
+ "CHECK (uses_unbounded IN (0, 1))"
1000
+ )
1001
+ conn.execute(
1002
+ "CREATE INDEX IF NOT EXISTS approval_requests_subject_idx "
1003
+ "ON approval_requests(subject_kind, status, created_at DESC)"
1004
+ )
1005
+ os.chmod(self.path, 0o600)
1006
+
1007
+ @staticmethod
1008
+ def _request_dict(row: sqlite3.Row) -> Dict[str, Any]:
1009
+ result = dict(row)
1010
+ result["scope"] = json.loads(result.pop("scope_json"))
1011
+ raw_presentation = result.pop("presentation_json", None)
1012
+ if raw_presentation:
1013
+ try:
1014
+ presentation = _validated_presentation(json.loads(raw_presentation))
1015
+ except (ApprovalAuthorityError, TypeError, ValueError) as exc:
1016
+ raise ApprovalAuthorityError(
1017
+ "presentation_integrity_failed",
1018
+ "stored approval presentation is invalid",
1019
+ ) from exc
1020
+ else:
1021
+ presentation = None
1022
+ if presentation is None:
1023
+ presentation = _validated_presentation({
1024
+ "schema": PRESENTATION_SCHEMA,
1025
+ "version": 1,
1026
+ "summary": result["scope"].get("action_name") or "legacy action",
1027
+ "action_name": result["scope"].get("action_name") or "legacy_action",
1028
+ "risk": result["scope"].get("risk") or "unknown",
1029
+ "effect": "unknown",
1030
+ "target": "unspecified",
1031
+ "capabilities": [],
1032
+ "deadline": "unspecified",
1033
+ "reversibility": "unspecified",
1034
+ "constraints": result["scope"].get("constraints") or {},
1035
+ })
1036
+ computed_presentation_digest = _digest(presentation)
1037
+ stored_presentation_digest = str(result.get("presentation_digest") or "")
1038
+ if (
1039
+ stored_presentation_digest
1040
+ and stored_presentation_digest != computed_presentation_digest
1041
+ ):
1042
+ raise ApprovalAuthorityError(
1043
+ "presentation_integrity_failed",
1044
+ "stored approval presentation does not match its digest",
1045
+ )
1046
+ result["presentation"] = presentation
1047
+ result["presentation_digest"] = computed_presentation_digest
1048
+ raw_subject = {
1049
+ "kind": result.pop("subject_kind", None),
1050
+ "subject_id": result.pop("subject_id", None),
1051
+ "revision": result.pop("subject_revision", None),
1052
+ "action": result.pop("subject_action", None),
1053
+ }
1054
+ stored_subject_digest = str(result.pop("subject_digest", None) or "")
1055
+ populated_subject_fields = [
1056
+ value not in (None, "") for value in raw_subject.values()
1057
+ ]
1058
+ if any(populated_subject_fields) and not all(populated_subject_fields):
1059
+ raise ApprovalAuthorityError(
1060
+ "subject_integrity_failed",
1061
+ "stored approval subject binding is incomplete",
1062
+ )
1063
+ subject = None
1064
+ computed_subject_digest = None
1065
+ if all(populated_subject_fields):
1066
+ subject = _validated_subject(raw_subject)
1067
+ computed_subject_digest = approval_subject_digest(
1068
+ subject,
1069
+ action_digest=result["action_digest"],
1070
+ scope_digest=result["scope_digest"],
1071
+ presentation_digest=computed_presentation_digest,
1072
+ )
1073
+ if stored_subject_digest != computed_subject_digest:
1074
+ raise ApprovalAuthorityError(
1075
+ "subject_integrity_failed",
1076
+ "stored approval subject binding does not match its digest",
1077
+ )
1078
+ elif stored_subject_digest:
1079
+ raise ApprovalAuthorityError(
1080
+ "subject_integrity_failed",
1081
+ "stored approval subject digest has no subject binding",
1082
+ )
1083
+ result["subject"] = subject
1084
+ result["subject_digest"] = computed_subject_digest
1085
+ job_type = str(result["scope"].get("job_type") or "")
1086
+ result["binding_digest"] = approval_binding_digest(
1087
+ job_id=result["job_id"],
1088
+ job_type=job_type,
1089
+ action_digest=result["action_digest"],
1090
+ scope_digest=result["scope_digest"],
1091
+ )
1092
+ request_digest_version = 2 if subject is not None else 1
1093
+ request_digest_payload = {
1094
+ "version": request_digest_version,
1095
+ "request_id": result["request_id"],
1096
+ "job_id": result["job_id"],
1097
+ "action_digest": result["action_digest"],
1098
+ "scope_digest": result["scope_digest"],
1099
+ "binding_digest": result["binding_digest"],
1100
+ "presentation_digest": result["presentation_digest"],
1101
+ "created_at": result["created_at"],
1102
+ "expires_at": result["expires_at"],
1103
+ }
1104
+ if subject is not None:
1105
+ request_digest_payload["subject_digest"] = computed_subject_digest
1106
+ result["request_digest"] = _digest(request_digest_payload)
1107
+ result["request_digest_version"] = request_digest_version
1108
+ return result
1109
+
1110
+ @staticmethod
1111
+ def _grant_dict(row: sqlite3.Row) -> Dict[str, Any]:
1112
+ result = dict(row)
1113
+ result["scope"] = json.loads(result.pop("scope_json"))
1114
+ result["ttl_unbounded"] = bool(result.get("ttl_unbounded", 0))
1115
+ if result["ttl_unbounded"]:
1116
+ result["expires_at"] = None
1117
+ result["uses_unbounded"] = bool(result.get("uses_unbounded", 0))
1118
+ if result["uses_unbounded"]:
1119
+ result["max_uses"] = None
1120
+ return result
1121
+
1122
+ @staticmethod
1123
+ def _grant_use_dict(row: sqlite3.Row) -> Dict[str, Any]:
1124
+ result = dict(row)
1125
+ result["grant_ttl_unbounded"] = bool(
1126
+ result.get("grant_ttl_unbounded", 0)
1127
+ )
1128
+ if result["grant_ttl_unbounded"]:
1129
+ result["grant_expires_at"] = None
1130
+ result["grant_uses_unbounded"] = bool(
1131
+ result.get("grant_uses_unbounded", 0)
1132
+ )
1133
+ if result["grant_uses_unbounded"]:
1134
+ result["max_uses"] = None
1135
+ return result
1136
+
1137
+ @classmethod
1138
+ def _request_view(
1139
+ cls,
1140
+ row: sqlite3.Row,
1141
+ *,
1142
+ now: Optional[datetime] = None,
1143
+ ) -> Dict[str, Any]:
1144
+ result = cls._request_dict(row)
1145
+ if (
1146
+ result["status"] == "pending"
1147
+ and datetime.fromisoformat(result["expires_at"]) <= _as_utc(now)
1148
+ ):
1149
+ # Read endpoints project expiry without mutating authority state.
1150
+ # Decision and scheduler transactions materialize it durably.
1151
+ result["status"] = "expired"
1152
+ return result
1153
+
1154
+ def _expire(self, conn: sqlite3.Connection, now: datetime) -> None:
1155
+ stamp = _iso(now)
1156
+ conn.execute(
1157
+ "UPDATE approval_requests SET status='expired' "
1158
+ "WHERE status='pending' AND expires_at<=?",
1159
+ (stamp,),
1160
+ )
1161
+ conn.execute(
1162
+ "UPDATE bounded_grants SET status='expired' "
1163
+ "WHERE status='active' AND ttl_unbounded=0 AND expires_at<=?",
1164
+ (stamp,),
1165
+ )
1166
+ conn.execute(
1167
+ "UPDATE bounded_grants SET status='exhausted' "
1168
+ "WHERE status='active' AND uses_unbounded=0 AND uses>=max_uses",
1169
+ )
1170
+
1171
+ def resolve_action_gate(
1172
+ self,
1173
+ *,
1174
+ job_id: str,
1175
+ binding: ActionBinding,
1176
+ operation_id: str,
1177
+ ttl_seconds: int,
1178
+ presentation: Mapping[str, Any],
1179
+ now: Optional[datetime] = None,
1180
+ ) -> Dict[str, Any]:
1181
+ """Atomically choose an existing request, a grant, or a new request.
1182
+
1183
+ Approval state and reusable grants share this SQLite database, so one
1184
+ ``BEGIN IMMEDIATE`` transaction can impose the authority ordering that
1185
+ the separate queue database cannot: any durable request for this job
1186
+ wins before a grant is considered. If no request exists, exactly one
1187
+ concurrent resolver may consume a grant or create the pending request.
1188
+ The queue transition remains a separately reconciled operation.
1189
+ """
1190
+
1191
+ if ttl_seconds < 0 or ttl_seconds > MAX_APPROVAL_REQUEST_TTL_SECONDS:
1192
+ raise ApprovalAuthorityError(
1193
+ "invalid_expiry", "request TTL is out of bounds"
1194
+ )
1195
+ operation = str(operation_id or "").strip()
1196
+ if not operation:
1197
+ raise ApprovalAuthorityError(
1198
+ "operation_id_required", "operation_id is required"
1199
+ )
1200
+ normalized_presentation = _validated_presentation(presentation)
1201
+ presentation_json = _canonical(normalized_presentation)
1202
+ presentation_digest = _digest(normalized_presentation)
1203
+ observed = _as_utc(now)
1204
+ request_id = "apr_" + uuid.uuid4().hex
1205
+
1206
+ with self._connect() as conn:
1207
+ conn.execute("BEGIN IMMEDIATE")
1208
+ self._expire(conn, observed)
1209
+ current = conn.execute(
1210
+ "SELECT * FROM approval_requests WHERE job_id=? "
1211
+ "ORDER BY created_at DESC, request_id DESC LIMIT 1",
1212
+ (str(job_id),),
1213
+ ).fetchone()
1214
+ if current is not None:
1215
+ if (
1216
+ current["action_digest"] != binding.action_digest
1217
+ or current["scope_digest"] != binding.scope_digest
1218
+ or _canonical(json.loads(current["scope_json"]))
1219
+ != _canonical(binding.scope)
1220
+ ):
1221
+ conn.rollback()
1222
+ raise ApprovalAuthorityError(
1223
+ "job_binding_conflict",
1224
+ "an approval request already binds this immutable job to another action",
1225
+ )
1226
+ stored_presentation_digest = str(
1227
+ current["presentation_digest"] or ""
1228
+ )
1229
+ if (
1230
+ stored_presentation_digest
1231
+ and stored_presentation_digest != presentation_digest
1232
+ ):
1233
+ conn.rollback()
1234
+ raise ApprovalAuthorityError(
1235
+ "presentation_mismatch",
1236
+ "approval presentation changed for an immutable action",
1237
+ )
1238
+ if not stored_presentation_digest:
1239
+ conn.execute(
1240
+ "UPDATE approval_requests SET presentation_json=?, "
1241
+ "presentation_digest=? WHERE request_id=? "
1242
+ "AND presentation_digest IS NULL",
1243
+ (
1244
+ presentation_json,
1245
+ presentation_digest,
1246
+ current["request_id"],
1247
+ ),
1248
+ )
1249
+ current = conn.execute(
1250
+ "SELECT * FROM approval_requests WHERE request_id=?",
1251
+ (current["request_id"],),
1252
+ ).fetchone()
1253
+ conn.commit()
1254
+ assert current is not None
1255
+ return {
1256
+ "kind": "request",
1257
+ "request": self._request_dict(current),
1258
+ "grant": None,
1259
+ "grant_use": None,
1260
+ }
1261
+
1262
+ # A job whose approval window closed cannot spend a reusable grant.
1263
+ # Returning no durable row is safe: the queue writes a terminal
1264
+ # state, and a crash simply retries this same deterministic result.
1265
+ if ttl_seconds == 0:
1266
+ conn.commit()
1267
+ return {
1268
+ "kind": "deadline_expired",
1269
+ "request": None,
1270
+ "grant": None,
1271
+ "grant_use": None,
1272
+ }
1273
+
1274
+ use_row = conn.execute(
1275
+ """SELECT u.action_digest, u.grant_id, u.operation_id,
1276
+ u.consumed_at, g.scope_digest, g.source_request_id,
1277
+ g.granted_by, g.decision_id, g.status AS grant_status,
1278
+ g.created_at AS grant_created_at,
1279
+ g.expires_at AS grant_expires_at,
1280
+ g.ttl_unbounded AS grant_ttl_unbounded,
1281
+ g.uses_unbounded AS grant_uses_unbounded,
1282
+ g.max_uses, g.uses
1283
+ FROM bounded_grant_uses AS u
1284
+ JOIN bounded_grants AS g ON g.grant_id = u.grant_id
1285
+ WHERE u.action_digest = ?""",
1286
+ (binding.action_digest,),
1287
+ ).fetchone()
1288
+ if use_row is not None:
1289
+ if use_row["operation_id"] != operation:
1290
+ conn.rollback()
1291
+ raise ApprovalAuthorityError(
1292
+ "grant_operation_conflict",
1293
+ "bounded grant use belongs to another operation",
1294
+ )
1295
+ conn.commit()
1296
+ return {
1297
+ "kind": "grant",
1298
+ "request": None,
1299
+ "grant": None,
1300
+ "grant_use": self._grant_use_dict(use_row),
1301
+ }
1302
+
1303
+ grant_row = conn.execute(
1304
+ "SELECT * FROM bounded_grants WHERE scope_digest=? "
1305
+ "AND status='active' "
1306
+ "AND (ttl_unbounded=1 OR expires_at>?) "
1307
+ "AND (uses_unbounded=1 OR uses<max_uses) "
1308
+ "ORDER BY ttl_unbounded ASC, expires_at ASC, created_at ASC "
1309
+ "LIMIT 1",
1310
+ (binding.scope_digest, _iso(observed)),
1311
+ ).fetchone()
1312
+ if grant_row is not None:
1313
+ if _canonical(json.loads(grant_row["scope_json"])) != _canonical(
1314
+ binding.scope
1315
+ ):
1316
+ conn.rollback()
1317
+ raise ApprovalAuthorityError(
1318
+ "scope_digest_collision", "grant scope mismatch"
1319
+ )
1320
+ conn.execute(
1321
+ "INSERT INTO bounded_grant_uses "
1322
+ "(action_digest, grant_id, operation_id, consumed_at) "
1323
+ "VALUES (?, ?, ?, ?)",
1324
+ (
1325
+ binding.action_digest,
1326
+ grant_row["grant_id"],
1327
+ operation,
1328
+ _iso(observed),
1329
+ ),
1330
+ )
1331
+ conn.execute(
1332
+ "UPDATE bounded_grants SET uses=uses+1, "
1333
+ "status=CASE WHEN uses_unbounded=0 AND uses+1>=max_uses "
1334
+ "THEN 'exhausted' ELSE status END WHERE grant_id=?",
1335
+ (grant_row["grant_id"],),
1336
+ )
1337
+ use_row = conn.execute(
1338
+ """SELECT u.action_digest, u.grant_id, u.operation_id,
1339
+ u.consumed_at, g.scope_digest,
1340
+ g.source_request_id, g.granted_by, g.decision_id,
1341
+ g.status AS grant_status,
1342
+ g.created_at AS grant_created_at,
1343
+ g.expires_at AS grant_expires_at,
1344
+ g.ttl_unbounded AS grant_ttl_unbounded,
1345
+ g.uses_unbounded AS grant_uses_unbounded,
1346
+ g.max_uses, g.uses
1347
+ FROM bounded_grant_uses AS u
1348
+ JOIN bounded_grants AS g ON g.grant_id = u.grant_id
1349
+ WHERE u.action_digest = ?""",
1350
+ (binding.action_digest,),
1351
+ ).fetchone()
1352
+ conn.commit()
1353
+ assert use_row is not None
1354
+ return {
1355
+ "kind": "grant",
1356
+ "request": None,
1357
+ "grant": self._grant_dict(grant_row),
1358
+ "grant_use": self._grant_use_dict(use_row),
1359
+ }
1360
+
1361
+ conn.execute(
1362
+ "INSERT INTO approval_requests "
1363
+ "(request_id, job_id, action_digest, scope_json, scope_digest, "
1364
+ "status, created_at, expires_at, presentation_json, "
1365
+ "presentation_digest) "
1366
+ "VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)",
1367
+ (
1368
+ request_id,
1369
+ str(job_id),
1370
+ binding.action_digest,
1371
+ _canonical(binding.scope),
1372
+ binding.scope_digest,
1373
+ _iso(observed),
1374
+ _iso(observed + timedelta(seconds=ttl_seconds)),
1375
+ presentation_json,
1376
+ presentation_digest,
1377
+ ),
1378
+ )
1379
+ request_row = conn.execute(
1380
+ "SELECT * FROM approval_requests WHERE request_id=?",
1381
+ (request_id,),
1382
+ ).fetchone()
1383
+ conn.commit()
1384
+ assert request_row is not None
1385
+ return {
1386
+ "kind": "request",
1387
+ "request": self._request_dict(request_row),
1388
+ "grant": None,
1389
+ "grant_use": None,
1390
+ }
1391
+
1392
+ def ensure_request(
1393
+ self,
1394
+ *,
1395
+ job_id: str,
1396
+ binding: ActionBinding,
1397
+ ttl_seconds: int = DEFAULT_REQUEST_TTL_SECONDS,
1398
+ presentation: Optional[Mapping[str, Any]] = None,
1399
+ subject: Optional[
1400
+ ApprovalSubjectBinding | Mapping[str, Any]
1401
+ ] = None,
1402
+ now: Optional[datetime] = None,
1403
+ ) -> Dict[str, Any]:
1404
+ """Return the current request, superseding any changed pending one.
1405
+
1406
+ A typed non-queue subject must be supplied when its request is first
1407
+ created. It is deliberately not backfilled onto an existing row: that
1408
+ would let an orphan or unrelated action be relabelled for a public
1409
+ decision surface after the fact.
1410
+ """
1411
+
1412
+ if ttl_seconds < 60 or ttl_seconds > MAX_APPROVAL_REQUEST_TTL_SECONDS:
1413
+ raise ApprovalAuthorityError("invalid_expiry", "request TTL is out of bounds")
1414
+ observed = _as_utc(now)
1415
+ presentation_supplied = presentation is not None
1416
+ fallback_presentation = _validated_presentation({
1417
+ "schema": PRESENTATION_SCHEMA,
1418
+ "version": 1,
1419
+ "summary": binding.scope.get("action_name") or "approval required",
1420
+ "action_name": binding.scope.get("action_name") or "legacy_action",
1421
+ "risk": binding.scope.get("risk") or "unknown",
1422
+ "effect": "unknown",
1423
+ "target": "unspecified",
1424
+ "capabilities": [],
1425
+ "deadline": "unspecified",
1426
+ "reversibility": "unspecified",
1427
+ "constraints": binding.scope.get("constraints") or {},
1428
+ })
1429
+ if presentation is None:
1430
+ normalized_presentation = fallback_presentation
1431
+ else:
1432
+ normalized_presentation = _validated_presentation(presentation)
1433
+ presentation_json = _canonical(normalized_presentation)
1434
+ presentation_digest = _digest(normalized_presentation)
1435
+ normalized_subject: Optional[Dict[str, str]] = None
1436
+ subject_digest: Optional[str] = None
1437
+ if subject is not None:
1438
+ normalized_subject = (
1439
+ subject.payload()
1440
+ if isinstance(subject, ApprovalSubjectBinding)
1441
+ else _validated_subject(subject)
1442
+ )
1443
+ subject_digest = approval_subject_digest(
1444
+ normalized_subject,
1445
+ action_digest=binding.action_digest,
1446
+ scope_digest=binding.scope_digest,
1447
+ presentation_digest=presentation_digest,
1448
+ )
1449
+ request_id = "apr_" + uuid.uuid4().hex
1450
+ with self._connect() as conn:
1451
+ conn.execute("BEGIN IMMEDIATE")
1452
+ self._expire(conn, observed)
1453
+ current = conn.execute(
1454
+ "SELECT * FROM approval_requests WHERE job_id=? "
1455
+ "ORDER BY created_at DESC LIMIT 1",
1456
+ (str(job_id),),
1457
+ ).fetchone()
1458
+ if (
1459
+ current is not None
1460
+ and current["action_digest"] == binding.action_digest
1461
+ and current["scope_digest"] == binding.scope_digest
1462
+ ):
1463
+ current_presentation_digest = str(
1464
+ current["presentation_digest"] or ""
1465
+ )
1466
+ if current_presentation_digest and presentation_supplied and (
1467
+ current_presentation_digest != presentation_digest
1468
+ ):
1469
+ conn.rollback()
1470
+ raise ApprovalAuthorityError(
1471
+ "presentation_mismatch",
1472
+ "approval presentation changed for an immutable action",
1473
+ )
1474
+ if not current_presentation_digest and presentation_supplied:
1475
+ # Migration-only backfill. The action digest already binds
1476
+ # the source payload from which this redacted view derives.
1477
+ conn.execute(
1478
+ "UPDATE approval_requests SET presentation_json=?, "
1479
+ "presentation_digest=? WHERE request_id=? "
1480
+ "AND presentation_digest IS NULL",
1481
+ (
1482
+ presentation_json,
1483
+ presentation_digest,
1484
+ current["request_id"],
1485
+ ),
1486
+ )
1487
+ current = conn.execute(
1488
+ "SELECT * FROM approval_requests WHERE request_id=?",
1489
+ (current["request_id"],),
1490
+ ).fetchone()
1491
+ current_subject_values = (
1492
+ current["subject_kind"], current["subject_id"],
1493
+ current["subject_revision"], current["subject_action"],
1494
+ )
1495
+ current_has_subject = any(
1496
+ value not in (None, "") for value in current_subject_values
1497
+ )
1498
+ if normalized_subject is not None:
1499
+ if not current_has_subject:
1500
+ conn.rollback()
1501
+ raise ApprovalAuthorityError(
1502
+ "subject_binding_conflict",
1503
+ "an existing approval request cannot be relabelled as a typed subject",
1504
+ )
1505
+ expected_values = (
1506
+ normalized_subject["kind"],
1507
+ normalized_subject["subject_id"],
1508
+ normalized_subject["revision"],
1509
+ normalized_subject["action"],
1510
+ )
1511
+ if (
1512
+ current_subject_values != expected_values
1513
+ or current["subject_digest"] != subject_digest
1514
+ ):
1515
+ conn.rollback()
1516
+ raise ApprovalAuthorityError(
1517
+ "subject_binding_conflict",
1518
+ "approval request subject binding is immutable",
1519
+ )
1520
+ conn.commit()
1521
+ return self._request_dict(current)
1522
+
1523
+ if current is not None and (
1524
+ normalized_subject is not None
1525
+ or current["subject_kind"] not in (None, "")
1526
+ ):
1527
+ conn.rollback()
1528
+ raise ApprovalAuthorityError(
1529
+ "subject_binding_conflict",
1530
+ "a typed approval subject cannot change its immutable action binding",
1531
+ )
1532
+
1533
+ if current is not None and current["status"] == "pending":
1534
+ conn.execute(
1535
+ "UPDATE approval_requests SET status='superseded', "
1536
+ "superseded_by=? WHERE request_id=? AND status='pending'",
1537
+ (request_id, current["request_id"]),
1538
+ )
1539
+
1540
+ conn.execute(
1541
+ "INSERT INTO approval_requests "
1542
+ "(request_id, job_id, action_digest, scope_json, scope_digest, "
1543
+ "status, created_at, expires_at, presentation_json, "
1544
+ "presentation_digest, subject_kind, subject_id, "
1545
+ "subject_revision, subject_action, subject_digest) "
1546
+ "VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1547
+ (
1548
+ request_id,
1549
+ str(job_id),
1550
+ binding.action_digest,
1551
+ _canonical(binding.scope),
1552
+ binding.scope_digest,
1553
+ _iso(observed),
1554
+ _iso(observed + timedelta(seconds=ttl_seconds)),
1555
+ presentation_json,
1556
+ presentation_digest,
1557
+ normalized_subject["kind"] if normalized_subject else None,
1558
+ normalized_subject["subject_id"] if normalized_subject else None,
1559
+ normalized_subject["revision"] if normalized_subject else None,
1560
+ normalized_subject["action"] if normalized_subject else None,
1561
+ subject_digest,
1562
+ ),
1563
+ )
1564
+ row = conn.execute(
1565
+ "SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
1566
+ ).fetchone()
1567
+ conn.commit()
1568
+ assert row is not None
1569
+ return self._request_dict(row)
1570
+
1571
+ def get_request(self, request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]:
1572
+ with self._connect() as conn:
1573
+ row = conn.execute(
1574
+ "SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
1575
+ ).fetchone()
1576
+ return self._request_view(row, now=now) if row is not None else None
1577
+
1578
+ def get_request_for_job(
1579
+ self,
1580
+ job_id: str,
1581
+ *,
1582
+ now: Optional[datetime] = None,
1583
+ ) -> Optional[Dict[str, Any]]:
1584
+ """Return the latest canonical request for one immutable queue job."""
1585
+
1586
+ with self._connect() as conn:
1587
+ row = conn.execute(
1588
+ "SELECT * FROM approval_requests WHERE job_id=? "
1589
+ "ORDER BY created_at DESC, request_id DESC LIMIT 1",
1590
+ (str(job_id),),
1591
+ ).fetchone()
1592
+ return self._request_view(row, now=now) if row is not None else None
1593
+
1594
+ def list_requests(
1595
+ self,
1596
+ *,
1597
+ status: Optional[str] = None,
1598
+ limit: int = 100,
1599
+ now: Optional[datetime] = None,
1600
+ ) -> list[Dict[str, Any]]:
1601
+ bounded_limit = max(1, min(int(limit), 500))
1602
+ observed = _as_utc(now)
1603
+ with self._connect() as conn:
1604
+ if status == "pending":
1605
+ rows = conn.execute(
1606
+ "SELECT * FROM approval_requests WHERE status='pending' "
1607
+ "AND expires_at>? ORDER BY created_at DESC LIMIT ?",
1608
+ (_iso(observed), bounded_limit),
1609
+ ).fetchall()
1610
+ elif status == "expired":
1611
+ rows = conn.execute(
1612
+ "SELECT * FROM approval_requests WHERE status='expired' "
1613
+ "OR (status='pending' AND expires_at<=?) "
1614
+ "ORDER BY created_at DESC LIMIT ?",
1615
+ (_iso(observed), bounded_limit),
1616
+ ).fetchall()
1617
+ elif status:
1618
+ rows = conn.execute(
1619
+ "SELECT * FROM approval_requests WHERE status=? "
1620
+ "ORDER BY created_at DESC LIMIT ?",
1621
+ (status, bounded_limit),
1622
+ ).fetchall()
1623
+ else:
1624
+ rows = conn.execute(
1625
+ "SELECT * FROM approval_requests ORDER BY created_at DESC LIMIT ?",
1626
+ (bounded_limit,),
1627
+ ).fetchall()
1628
+ return [self._request_view(row, now=observed) for row in rows]
1629
+
1630
+ def list_subject_requests(
1631
+ self,
1632
+ *,
1633
+ subject_kind: str,
1634
+ status: Optional[str] = None,
1635
+ limit: int = 100,
1636
+ now: Optional[datetime] = None,
1637
+ ) -> list[Dict[str, Any]]:
1638
+ """Return typed candidates for a domain service to validate.
1639
+
1640
+ This is intentionally not itself a public authorization boundary. The
1641
+ owning domain must still prove the subject exists and recompute its
1642
+ domain binding before projecting or deciding a candidate.
1643
+ """
1644
+
1645
+ inspection = self.inspect_subject_requests(
1646
+ subject_kind=subject_kind,
1647
+ status=status,
1648
+ limit=limit,
1649
+ now=now,
1650
+ )
1651
+ return inspection["requests"]
1652
+
1653
+ def inspect_subject_requests(
1654
+ self,
1655
+ *,
1656
+ subject_kind: str,
1657
+ status: Optional[str] = None,
1658
+ limit: int = 100,
1659
+ job_prefix: Optional[str] = None,
1660
+ now: Optional[datetime] = None,
1661
+ ) -> Dict[str, Any]:
1662
+ """Inspect typed candidates while counting undisclosed invalid rows."""
1663
+
1664
+ kind = str(subject_kind or "").strip()
1665
+ if not _SUBJECT_KIND_RE.fullmatch(kind):
1666
+ raise ApprovalAuthorityError(
1667
+ "invalid_subject_binding", "approval subject kind is invalid",
1668
+ )
1669
+ bounded_limit = max(1, min(int(limit), 500))
1670
+ observed = _as_utc(now)
1671
+ prefix = str(job_prefix or "").strip()
1672
+ if prefix and (
1673
+ len(prefix) > 128 or _CONTROL.search(prefix)
1674
+ or not _ACTION_RE.fullmatch(prefix.rstrip(":"))
1675
+ ):
1676
+ raise ApprovalAuthorityError(
1677
+ "invalid_subject_binding", "approval job prefix is invalid",
1678
+ )
1679
+ predicates = [
1680
+ "(subject_kind=? OR job_id LIKE ? ESCAPE '\\')"
1681
+ if prefix else "subject_kind=?",
1682
+ ]
1683
+ values: list[Any] = [kind]
1684
+ if prefix:
1685
+ values.append(prefix.replace("%", "\\%").replace("_", "\\_") + "%")
1686
+ if status == "pending":
1687
+ predicates.extend(["status='pending'", "expires_at>?"])
1688
+ values.append(_iso(observed))
1689
+ elif status == "expired":
1690
+ predicates.append(
1691
+ "(status='expired' OR (status='pending' AND expires_at<=?))"
1692
+ )
1693
+ values.append(_iso(observed))
1694
+ elif status:
1695
+ predicates.append("status=?")
1696
+ values.append(str(status))
1697
+ values.append(bounded_limit)
1698
+ with self._connect() as conn:
1699
+ rows = conn.execute(
1700
+ "SELECT * FROM approval_requests WHERE "
1701
+ + " AND ".join(predicates)
1702
+ + " ORDER BY created_at DESC, request_id DESC LIMIT ?",
1703
+ values,
1704
+ ).fetchall()
1705
+ requests: list[Dict[str, Any]] = []
1706
+ invalid_count = 0
1707
+ for row in rows:
1708
+ try:
1709
+ request = self._request_view(row, now=observed)
1710
+ except ApprovalAuthorityError:
1711
+ invalid_count += 1
1712
+ continue
1713
+ subject = request.get("subject")
1714
+ if not isinstance(subject, Mapping) or subject.get("kind") != kind:
1715
+ invalid_count += 1
1716
+ continue
1717
+ requests.append(request)
1718
+ return {
1719
+ "requests": requests,
1720
+ "invalid_hidden_count": invalid_count,
1721
+ "candidate_count": len(rows),
1722
+ "complete": len(rows) < bounded_limit,
1723
+ }
1724
+
1725
+ def decide(
1726
+ self,
1727
+ request_id: str,
1728
+ *,
1729
+ decision: str,
1730
+ decision_id: str,
1731
+ expected_action_digest: str,
1732
+ decided_by: str,
1733
+ authority_evidence: str,
1734
+ grant_scope: Optional[Mapping[str, Any]] = None,
1735
+ grant_ttl_seconds: int = DEFAULT_GRANT_TTL_SECONDS,
1736
+ grant_max_uses: int = DEFAULT_GRANT_MAX_USES,
1737
+ now: Optional[datetime] = None,
1738
+ ) -> Dict[str, Any]:
1739
+ """Record the first valid decision and optionally mint one grant.
1740
+
1741
+ Repeating the exact same decision id is idempotent. A different
1742
+ replay, an opposite decision, or a decision against changed content is
1743
+ rejected and cannot alter the winner.
1744
+ """
1745
+
1746
+ normalized = str(decision).strip().lower()
1747
+ if normalized not in {"approve", "reject"}:
1748
+ raise ApprovalAuthorityError("invalid_decision", "decision must be approve or reject")
1749
+ if not _DECISION_ID_RE.fullmatch(str(decision_id or "")):
1750
+ raise ApprovalAuthorityError("invalid_decision_id", "decision_id is malformed")
1751
+ actor = str(decided_by or "").strip()
1752
+ evidence = str(authority_evidence or "").strip()
1753
+ if not actor or not evidence:
1754
+ raise ApprovalAuthorityError(
1755
+ "authority_required", "server-derived decision authority is required"
1756
+ )
1757
+ if len(evidence) > 512 or _CONTROL.search(evidence):
1758
+ raise ApprovalAuthorityError(
1759
+ "invalid_authority_evidence",
1760
+ "decision authority evidence is invalid or exceeds 512 characters",
1761
+ )
1762
+ ttl_unbounded = self.grant_envelope.max_ttl_seconds is None
1763
+ uses_unbounded = self.grant_envelope.max_uses is None
1764
+ if grant_scope is not None:
1765
+ if normalized != "approve":
1766
+ raise ApprovalAuthorityError("invalid_grant", "a rejection cannot create a grant")
1767
+ if grant_ttl_seconds < 60 or (
1768
+ self.grant_envelope.max_ttl_seconds is not None
1769
+ and grant_ttl_seconds > self.grant_envelope.max_ttl_seconds
1770
+ ):
1771
+ raise ApprovalAuthorityError("invalid_grant_expiry", "grant expiry is out of bounds")
1772
+ if grant_max_uses < 1 or (
1773
+ self.grant_envelope.max_uses is not None
1774
+ and grant_max_uses > self.grant_envelope.max_uses
1775
+ ):
1776
+ raise ApprovalAuthorityError("invalid_grant_uses", "grant use bound is out of bounds")
1777
+
1778
+ observed = _as_utc(now)
1779
+ grant_expires_at = observed
1780
+ if grant_scope is not None and not ttl_unbounded:
1781
+ try:
1782
+ grant_expires_at = observed + timedelta(seconds=grant_ttl_seconds)
1783
+ except OverflowError as exc:
1784
+ raise ApprovalAuthorityError(
1785
+ "invalid_grant_expiry", "grant expiry is out of bounds"
1786
+ ) from exc
1787
+ effective_max_uses = 0 if uses_unbounded else int(grant_max_uses)
1788
+ with self._connect() as conn:
1789
+ conn.execute("BEGIN IMMEDIATE")
1790
+ self._expire(conn, observed)
1791
+ row = conn.execute(
1792
+ "SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
1793
+ ).fetchone()
1794
+ if row is None:
1795
+ conn.rollback()
1796
+ raise ApprovalAuthorityError("request_not_found", "approval request was not found")
1797
+ if row["action_digest"] != expected_action_digest:
1798
+ conn.rollback()
1799
+ raise ApprovalAuthorityError(
1800
+ "stale_action_digest", "decision does not match the current immutable action"
1801
+ )
1802
+
1803
+ replayed_elsewhere = conn.execute(
1804
+ "SELECT request_id FROM approval_requests WHERE decision_id=?",
1805
+ (decision_id,),
1806
+ ).fetchone()
1807
+ if (
1808
+ replayed_elsewhere is not None
1809
+ and replayed_elsewhere["request_id"] != request_id
1810
+ ):
1811
+ conn.rollback()
1812
+ raise ApprovalAuthorityError(
1813
+ "decision_replay", "decision_id was already used for another request"
1814
+ )
1815
+
1816
+ status = row["status"]
1817
+ if status in {"approved", "rejected"}:
1818
+ if row["decision_id"] == decision_id and row["decision"] == normalized:
1819
+ grant = None
1820
+ if row["grant_id"]:
1821
+ grant_row = conn.execute(
1822
+ "SELECT * FROM bounded_grants WHERE grant_id=?", (row["grant_id"],)
1823
+ ).fetchone()
1824
+ grant = self._grant_dict(grant_row) if grant_row is not None else None
1825
+ conn.commit()
1826
+ return {
1827
+ "request": self._request_dict(row),
1828
+ "grant": grant,
1829
+ "replayed": True,
1830
+ }
1831
+ conn.rollback()
1832
+ raise ApprovalAuthorityError(
1833
+ "decision_already_final", "the first valid decision is already final"
1834
+ )
1835
+ if status == "expired":
1836
+ conn.rollback()
1837
+ raise ApprovalAuthorityError("request_expired", "approval request has expired")
1838
+ if status == "superseded":
1839
+ conn.rollback()
1840
+ raise ApprovalAuthorityError("request_superseded", "approval request was superseded")
1841
+ if status != "pending":
1842
+ conn.rollback()
1843
+ raise ApprovalAuthorityError("request_not_pending", "approval request is not pending")
1844
+
1845
+ grant_id: Optional[str] = None
1846
+ grant: Optional[Dict[str, Any]] = None
1847
+ if grant_scope is not None:
1848
+ stored_scope = json.loads(row["scope_json"])
1849
+ if _canonical(dict(grant_scope)) != _canonical(stored_scope):
1850
+ conn.rollback()
1851
+ raise ApprovalAuthorityError(
1852
+ "scope_broadening",
1853
+ "bounded grant scope must exactly match the approved action scope",
1854
+ )
1855
+ grant_id = "grt_" + hashlib.sha256(
1856
+ f"{request_id}:{decision_id}".encode("utf-8")
1857
+ ).hexdigest()[:32]
1858
+ conn.execute(
1859
+ "INSERT INTO bounded_grants "
1860
+ "(grant_id, source_request_id, scope_json, scope_digest, status, "
1861
+ "created_at, expires_at, ttl_unbounded, max_uses, "
1862
+ "uses_unbounded, uses, granted_by, decision_id) "
1863
+ "VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, 0, ?, ?)",
1864
+ (
1865
+ grant_id,
1866
+ request_id,
1867
+ row["scope_json"],
1868
+ row["scope_digest"],
1869
+ _iso(observed),
1870
+ _iso(grant_expires_at),
1871
+ int(ttl_unbounded),
1872
+ effective_max_uses,
1873
+ int(uses_unbounded),
1874
+ actor,
1875
+ decision_id,
1876
+ ),
1877
+ )
1878
+
1879
+ final_status = "approved" if normalized == "approve" else "rejected"
1880
+ conn.execute(
1881
+ "UPDATE approval_requests SET status=?, decision=?, decision_id=?, "
1882
+ "decided_at=?, decided_by=?, authority_evidence=?, grant_id=? "
1883
+ "WHERE request_id=? AND status='pending'",
1884
+ (
1885
+ final_status,
1886
+ normalized,
1887
+ decision_id,
1888
+ _iso(observed),
1889
+ actor,
1890
+ evidence,
1891
+ grant_id,
1892
+ request_id,
1893
+ ),
1894
+ )
1895
+ final_row = conn.execute(
1896
+ "SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
1897
+ ).fetchone()
1898
+ if grant_id:
1899
+ grant_row = conn.execute(
1900
+ "SELECT * FROM bounded_grants WHERE grant_id=?", (grant_id,)
1901
+ ).fetchone()
1902
+ grant = self._grant_dict(grant_row) if grant_row is not None else None
1903
+ conn.commit()
1904
+ assert final_row is not None
1905
+ return {
1906
+ "request": self._request_dict(final_row),
1907
+ "grant": grant,
1908
+ "replayed": False,
1909
+ }
1910
+
1911
+ def supersede(
1912
+ self,
1913
+ request_id: str,
1914
+ *,
1915
+ replaced_by: str,
1916
+ ) -> Dict[str, Any]:
1917
+ with self._connect() as conn:
1918
+ conn.execute("BEGIN IMMEDIATE")
1919
+ row = conn.execute(
1920
+ "SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
1921
+ ).fetchone()
1922
+ if row is None:
1923
+ conn.rollback()
1924
+ raise ApprovalAuthorityError("request_not_found", "approval request was not found")
1925
+ if row["status"] != "pending":
1926
+ conn.rollback()
1927
+ raise ApprovalAuthorityError(
1928
+ "request_not_pending", "only a pending request can be superseded"
1929
+ )
1930
+ conn.execute(
1931
+ "UPDATE approval_requests SET status='superseded', superseded_by=? "
1932
+ "WHERE request_id=? AND status='pending'",
1933
+ (str(replaced_by), request_id),
1934
+ )
1935
+ final_row = conn.execute(
1936
+ "SELECT * FROM approval_requests WHERE request_id=?", (request_id,)
1937
+ ).fetchone()
1938
+ conn.commit()
1939
+ assert final_row is not None
1940
+ return self._request_dict(final_row)
1941
+
1942
+ def consume_grant(
1943
+ self,
1944
+ *,
1945
+ binding: ActionBinding,
1946
+ operation_id: str,
1947
+ now: Optional[datetime] = None,
1948
+ ) -> Optional[Dict[str, Any]]:
1949
+ """Atomically consume one matching use; retries are idempotent."""
1950
+
1951
+ observed = _as_utc(now)
1952
+ operation = str(operation_id or "").strip()
1953
+ if not operation:
1954
+ raise ApprovalAuthorityError("operation_id_required", "operation_id is required")
1955
+ with self._connect() as conn:
1956
+ conn.execute("BEGIN IMMEDIATE")
1957
+ self._expire(conn, observed)
1958
+ existing = conn.execute(
1959
+ "SELECT g.* FROM bounded_grant_uses u "
1960
+ "JOIN bounded_grants g ON g.grant_id=u.grant_id "
1961
+ "WHERE u.action_digest=?",
1962
+ (binding.action_digest,),
1963
+ ).fetchone()
1964
+ if existing is not None:
1965
+ conn.commit()
1966
+ result = self._grant_dict(existing)
1967
+ result["idempotent_reuse"] = True
1968
+ return result
1969
+
1970
+ row = conn.execute(
1971
+ "SELECT * FROM bounded_grants WHERE scope_digest=? "
1972
+ "AND status='active' "
1973
+ "AND (ttl_unbounded=1 OR expires_at>?) "
1974
+ "AND (uses_unbounded=1 OR uses<max_uses) "
1975
+ "ORDER BY ttl_unbounded ASC, expires_at ASC, created_at ASC "
1976
+ "LIMIT 1",
1977
+ (binding.scope_digest, _iso(observed)),
1978
+ ).fetchone()
1979
+ if row is None:
1980
+ conn.commit()
1981
+ return None
1982
+ if _canonical(json.loads(row["scope_json"])) != _canonical(binding.scope):
1983
+ # Hash collisions are fantastically unlikely, but authority
1984
+ # code compares canonical scope too instead of relying on one.
1985
+ conn.rollback()
1986
+ raise ApprovalAuthorityError("scope_digest_collision", "grant scope mismatch")
1987
+
1988
+ conn.execute(
1989
+ "INSERT INTO bounded_grant_uses "
1990
+ "(action_digest, grant_id, operation_id, consumed_at) VALUES (?, ?, ?, ?)",
1991
+ (binding.action_digest, row["grant_id"], operation, _iso(observed)),
1992
+ )
1993
+ conn.execute(
1994
+ "UPDATE bounded_grants SET uses=uses+1, "
1995
+ "status=CASE WHEN uses_unbounded=0 AND uses+1>=max_uses "
1996
+ "THEN 'exhausted' ELSE status END "
1997
+ "WHERE grant_id=?",
1998
+ (row["grant_id"],),
1999
+ )
2000
+ final_row = conn.execute(
2001
+ "SELECT * FROM bounded_grants WHERE grant_id=?", (row["grant_id"],)
2002
+ ).fetchone()
2003
+ conn.commit()
2004
+ assert final_row is not None
2005
+ result = self._grant_dict(final_row)
2006
+ result["idempotent_reuse"] = False
2007
+ return result
2008
+
2009
+ def list_grants(
2010
+ self,
2011
+ *,
2012
+ status: Optional[str] = None,
2013
+ now: Optional[datetime] = None,
2014
+ ) -> list[Dict[str, Any]]:
2015
+ with self._connect() as conn:
2016
+ conn.execute("BEGIN IMMEDIATE")
2017
+ self._expire(conn, _as_utc(now))
2018
+ if status:
2019
+ rows = conn.execute(
2020
+ "SELECT * FROM bounded_grants WHERE status=? ORDER BY created_at DESC",
2021
+ (status,),
2022
+ ).fetchall()
2023
+ else:
2024
+ rows = conn.execute(
2025
+ "SELECT * FROM bounded_grants ORDER BY created_at DESC"
2026
+ ).fetchall()
2027
+ conn.commit()
2028
+ return [self._grant_dict(row) for row in rows]
2029
+
2030
+ def grant_posture(self, *, now: Optional[datetime] = None) -> Dict[str, Any]:
2031
+ """Return the issuance envelope plus active persisted standing state.
2032
+
2033
+ Changing the deployment envelope affects newly issued grants; it does
2034
+ not silently narrow authority already granted by an owner decision.
2035
+ Report that durable inventory so a bounded restart can never make
2036
+ active standing authority disappear from operator diagnostics.
2037
+ """
2038
+
2039
+ observed = _as_utc(now)
2040
+ with self._connect() as conn:
2041
+ conn.execute("BEGIN IMMEDIATE")
2042
+ self._expire(conn, observed)
2043
+ row = conn.execute(
2044
+ "SELECT COUNT(*) AS active_standing_grants, "
2045
+ "SUM(CASE WHEN ttl_unbounded=1 THEN 1 ELSE 0 END) "
2046
+ "AS active_no_expiry_grants, "
2047
+ "SUM(CASE WHEN uses_unbounded=1 THEN 1 ELSE 0 END) "
2048
+ "AS active_no_use_cap_grants "
2049
+ "FROM bounded_grants WHERE status='active' "
2050
+ "AND (ttl_unbounded=1 OR uses_unbounded=1)"
2051
+ ).fetchone()
2052
+ conn.commit()
2053
+ posture = grant_envelope_posture(self.grant_envelope)
2054
+ posture.update({
2055
+ "active_standing_grants": int(row["active_standing_grants"] or 0),
2056
+ "active_no_expiry_grants": int(
2057
+ row["active_no_expiry_grants"] or 0
2058
+ ),
2059
+ "active_no_use_cap_grants": int(
2060
+ row["active_no_use_cap_grants"] or 0
2061
+ ),
2062
+ })
2063
+ return posture
2064
+
2065
+ def get_grant_use(self, action_digest: str) -> Optional[Dict[str, Any]]:
2066
+ """Return durable bounded-grant consumption evidence for one action."""
2067
+
2068
+ digest = str(action_digest or "").strip()
2069
+ if not digest:
2070
+ return None
2071
+ with self._connect() as conn:
2072
+ row = conn.execute(
2073
+ """SELECT u.action_digest, u.grant_id, u.operation_id,
2074
+ u.consumed_at, g.scope_digest, g.source_request_id,
2075
+ g.granted_by, g.decision_id, g.status AS grant_status,
2076
+ g.created_at AS grant_created_at,
2077
+ g.expires_at AS grant_expires_at,
2078
+ g.ttl_unbounded AS grant_ttl_unbounded,
2079
+ g.uses_unbounded AS grant_uses_unbounded,
2080
+ g.max_uses, g.uses
2081
+ FROM bounded_grant_uses AS u
2082
+ JOIN bounded_grants AS g ON g.grant_id = u.grant_id
2083
+ WHERE u.action_digest = ?""",
2084
+ (digest,),
2085
+ ).fetchone()
2086
+ return self._grant_use_dict(row) if row is not None else None
2087
+
2088
+ def revoke_grant(self, grant_id: str, *, now: Optional[datetime] = None) -> bool:
2089
+ with self._connect() as conn:
2090
+ conn.execute("BEGIN IMMEDIATE")
2091
+ cursor = conn.execute(
2092
+ "UPDATE bounded_grants SET status='revoked', revoked_at=? "
2093
+ "WHERE grant_id=? AND status IN ('active', 'exhausted')",
2094
+ (_iso(_as_utc(now)), grant_id),
2095
+ )
2096
+ changed = cursor.rowcount > 0
2097
+ conn.commit()
2098
+ return changed
2099
+
2100
+
2101
+ def authority_mode() -> str:
2102
+ """Return strict approval authority mode, preserving invalid config."""
2103
+
2104
+ value = os.environ.get("COLONY_APPROVAL_AUTHORITY_MODE", "shadow").strip().lower()
2105
+ return value if value in {"shadow", "enforce"} else "invalid"