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,1203 @@
1
+ """Authenticated API principals and server-derived person authority.
2
+
3
+ The legacy ``COLONY_API_KEY`` remains a migration credential. New scoped
4
+ credentials are loaded from a private JSON keyring and bind a service
5
+ principal to exact API scopes, a viewer/person, and explicit audience lanes.
6
+ Request bodies may narrow to a granted lane; they cannot broaden authority.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ import hmac
14
+ import json
15
+ import logging
16
+ import math
17
+ import os
18
+ from pathlib import Path
19
+ import re
20
+ import stat
21
+ import threading
22
+ from typing import Any, Mapping
23
+
24
+ from fastapi import HTTPException
25
+ from starlette.requests import Request
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$")
31
+ _CREDENTIAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,191}$")
32
+ _PERSON_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/+\-]{0,127}$")
33
+ _SCOPE_RE = re.compile(r"^(?:\*|[a-z][a-z0-9_.-]*:[a-z][a-z0-9_.-]*)$")
34
+ _PLATFORM_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
35
+ _WORKER_TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$")
36
+ _RESERVED_PRINCIPALS = frozenset({"legacy", "anonymous-dev", "unauthenticated", "public"})
37
+ _AUDIENCES = frozenset({"viewer", "owner", "shared", "global"})
38
+ _ACCEPTING_STATUSES = frozenset({"active", "retiring"})
39
+ _KNOWN_STATUSES = frozenset({"active", "retiring", "disabled", "revoked"})
40
+ _GOVERNED_ACTION_PRINCIPAL = "host-action-worker"
41
+ _GOVERNED_ACTION_SCOPES = frozenset({"actions:execute", "actions:verify"})
42
+
43
+ # Canonical read-only work surfaces used to assemble an operator's current
44
+ # working set. Keep this exact: collection reads belong to ``work:read``;
45
+ # neighbouring item reads and every mutation retain their existing authority.
46
+ WORK_READ_SURFACE_V1 = frozenset({
47
+ ("GET", "/v1/host/goals"),
48
+ ("GET", "/v1/host/projects"),
49
+ ("GET", "/v1/host/queue/jobs/pending"),
50
+ ("GET", "/v1/host/queue/jobs/neutral"),
51
+ })
52
+ _API_ACCESS_COMPATIBILITY = frozenset({"api:access"})
53
+ _NO_COMPATIBILITY_SCOPES: frozenset[str] = frozenset()
54
+
55
+
56
+ class KeyringError(ValueError):
57
+ """The configured keyring is unsafe or malformed."""
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Credential:
62
+ credential_id: str
63
+ secret: str
64
+ status: str
65
+ accept_until: datetime | None
66
+
67
+ def accepts(self, now: datetime) -> bool:
68
+ return _accepts(self.status, self.accept_until, now)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class WorkerGrant:
73
+ """Server-owned ceiling for one worker node authenticated by a principal."""
74
+
75
+ node_id: str
76
+ capabilities: frozenset[str]
77
+ capacity: tuple[tuple[str, float], ...]
78
+ max_concurrent: int
79
+ job_types: frozenset[str]
80
+
81
+ def capacity_map(self) -> dict[str, float]:
82
+ return dict(self.capacity)
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Principal:
87
+ principal_id: str
88
+ status: str
89
+ accept_until: datetime | None
90
+ scopes: frozenset[str]
91
+ allow_unscoped_api: bool
92
+ viewer_person_id: str | None
93
+ person_ids: frozenset[str]
94
+ audiences: frozenset[str]
95
+ turn_ingress_platforms: frozenset[str]
96
+ attested_contact_platforms: frozenset[str]
97
+ attested_contact_limit: int
98
+ worker_grants: tuple[WorkerGrant, ...]
99
+ credentials: tuple[Credential, ...]
100
+
101
+ def accepts(self, now: datetime) -> bool:
102
+ return _accepts(self.status, self.accept_until, now)
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class AuthenticatedCredential:
107
+ principal: Principal
108
+ credential: Credential
109
+
110
+ def accepts(self, now: datetime | None = None) -> bool:
111
+ current = now or datetime.now(timezone.utc)
112
+ return self.principal.accepts(current) and self.credential.accepts(current)
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class RequestAuthority:
117
+ """Authority attached to ``request.state`` by the auth middleware."""
118
+
119
+ principal_id: str
120
+ credential_id: str | None
121
+ scopes: frozenset[str]
122
+ viewer_person_id: str | None
123
+ person_ids: frozenset[str]
124
+ audiences: frozenset[str]
125
+ authenticated: bool
126
+ static_person_ids: frozenset[str] = frozenset()
127
+ turn_ingress_platforms: frozenset[str] = frozenset()
128
+ allow_unscoped_api: bool = True
129
+ legacy: bool = False
130
+ anonymous: bool = False
131
+ attested_contact_platforms: frozenset[str] = frozenset()
132
+ attested_contact_limit: int = 0
133
+ worker_grants: tuple[WorkerGrant, ...] = ()
134
+
135
+ def has_scope(self, required: str) -> bool:
136
+ return self.legacy or "*" in self.scopes or required in self.scopes
137
+
138
+
139
+ def _clean_string(value: Any, *, field: str, required: bool = False) -> str | None:
140
+ if value is None:
141
+ if required:
142
+ raise KeyringError(f"{field} is required")
143
+ return None
144
+ if not isinstance(value, str):
145
+ raise KeyringError(f"{field} must be a string")
146
+ cleaned = value.strip()
147
+ if not cleaned:
148
+ if required:
149
+ raise KeyringError(f"{field} must not be empty")
150
+ return None
151
+ return cleaned
152
+
153
+
154
+ def _parse_time(value: Any, *, field: str) -> datetime | None:
155
+ cleaned = _clean_string(value, field=field)
156
+ if cleaned is None:
157
+ return None
158
+ if cleaned.endswith("Z"):
159
+ cleaned = cleaned[:-1] + "+00:00"
160
+ try:
161
+ parsed = datetime.fromisoformat(cleaned)
162
+ except ValueError as exc:
163
+ raise KeyringError(f"{field} must be an RFC3339 timestamp") from exc
164
+ if parsed.tzinfo is None:
165
+ raise KeyringError(f"{field} must include a timezone")
166
+ return parsed.astimezone(timezone.utc)
167
+
168
+
169
+ def _parse_status(value: Any, *, field: str) -> str:
170
+ status = _clean_string(value if value is not None else "active", field=field, required=True)
171
+ assert status is not None
172
+ status = status.lower()
173
+ if status not in _KNOWN_STATUSES:
174
+ raise KeyringError(
175
+ f"{field} must be one of {', '.join(sorted(_KNOWN_STATUSES))}"
176
+ )
177
+ return status
178
+
179
+
180
+ def _accepts(status: str, accept_until: datetime | None, now: datetime) -> bool:
181
+ if status not in _ACCEPTING_STATUSES:
182
+ return False
183
+ if accept_until is not None and now >= accept_until:
184
+ return False
185
+ return True
186
+
187
+
188
+ def _string_set(value: Any, *, field: str) -> frozenset[str]:
189
+ if value is None:
190
+ return frozenset()
191
+ if not isinstance(value, list):
192
+ raise KeyringError(f"{field} must be a JSON array")
193
+ result: set[str] = set()
194
+ for index, item in enumerate(value):
195
+ cleaned = _clean_string(item, field=f"{field}[{index}]", required=True)
196
+ assert cleaned is not None
197
+ result.add(cleaned)
198
+ return frozenset(result)
199
+
200
+
201
+ def _person_id(value: Any, *, field: str) -> str:
202
+ cleaned = _clean_string(value, field=field, required=True)
203
+ assert cleaned is not None
204
+ if "*" in cleaned:
205
+ raise KeyringError(f"{field} cannot contain a wildcard")
206
+ if value != cleaned or not _PERSON_ID_RE.fullmatch(cleaned):
207
+ raise KeyringError(
208
+ f"{field} must be a canonical person ID of at most 128 characters"
209
+ )
210
+ return cleaned
211
+
212
+
213
+ def _person_id_set(value: Any, *, field: str) -> frozenset[str]:
214
+ if value is None:
215
+ return frozenset()
216
+ if not isinstance(value, list):
217
+ raise KeyringError(f"{field} must be a JSON array")
218
+ return frozenset(
219
+ _person_id(item, field=f"{field}[{index}]")
220
+ for index, item in enumerate(value)
221
+ )
222
+
223
+
224
+ def _parse_worker_grants(
225
+ value: Any,
226
+ *,
227
+ field: str,
228
+ scopes: frozenset[str],
229
+ ) -> tuple[WorkerGrant, ...]:
230
+ if value is None:
231
+ return ()
232
+ if not isinstance(value, list):
233
+ raise KeyringError(f"{field} must be a JSON array")
234
+ if len(value) > 64:
235
+ raise KeyringError(f"{field} supports at most 64 exact nodes")
236
+ grants: list[WorkerGrant] = []
237
+ seen_nodes: set[str] = set()
238
+ for index, raw in enumerate(value):
239
+ prefix = f"{field}[{index}]"
240
+ if not isinstance(raw, Mapping):
241
+ raise KeyringError(f"{prefix} must be an object")
242
+ unknown = set(raw) - {
243
+ "node_id", "capabilities", "capacity", "max_concurrent", "job_types",
244
+ }
245
+ if unknown:
246
+ raise KeyringError(f"{prefix} contains unsupported fields")
247
+ node_id = _clean_string(
248
+ raw.get("node_id"), field=f"{prefix}.node_id", required=True,
249
+ )
250
+ assert node_id is not None
251
+ if not _WORKER_TOKEN_RE.fullmatch(node_id) or "*" in node_id:
252
+ raise KeyringError(f"{prefix}.node_id is invalid")
253
+ if node_id in seen_nodes:
254
+ raise KeyringError(f"{field} contains duplicate node_id {node_id!r}")
255
+ seen_nodes.add(node_id)
256
+
257
+ capabilities = _string_set(
258
+ raw.get("capabilities"), field=f"{prefix}.capabilities",
259
+ )
260
+ if any(
261
+ not _WORKER_TOKEN_RE.fullmatch(item) or "*" in item
262
+ for item in capabilities
263
+ ):
264
+ raise KeyringError(f"{prefix}.capabilities contains an invalid name")
265
+ job_types = _string_set(
266
+ raw.get("job_types"), field=f"{prefix}.job_types",
267
+ )
268
+ if not job_types:
269
+ raise KeyringError(f"{prefix}.job_types must not be empty")
270
+ if any(
271
+ not _WORKER_TOKEN_RE.fullmatch(item) or "*" in item
272
+ for item in job_types
273
+ ):
274
+ raise KeyringError(f"{prefix}.job_types contains an invalid name")
275
+ from apsimo.task_queue.models import JobType
276
+ known_job_types = {item.value for item in JobType}
277
+ unknown_job_types = job_types - known_job_types
278
+ if unknown_job_types:
279
+ raise KeyringError(
280
+ f"{prefix}.job_types contains unsupported values: "
281
+ f"{', '.join(sorted(unknown_job_types))}"
282
+ )
283
+
284
+ raw_capacity = raw.get("capacity", {})
285
+ if not isinstance(raw_capacity, Mapping):
286
+ raise KeyringError(f"{prefix}.capacity must be an object")
287
+ capacity: list[tuple[str, float]] = []
288
+ for key, raw_amount in raw_capacity.items():
289
+ if (
290
+ not isinstance(key, str)
291
+ or not _WORKER_TOKEN_RE.fullmatch(key)
292
+ or "*" in key
293
+ ):
294
+ raise KeyringError(f"{prefix}.capacity contains an invalid name")
295
+ if isinstance(raw_amount, bool) or not isinstance(raw_amount, (int, float)):
296
+ raise KeyringError(f"{prefix}.capacity.{key} must be numeric")
297
+ amount = float(raw_amount)
298
+ if not math.isfinite(amount) or amount < 0:
299
+ raise KeyringError(
300
+ f"{prefix}.capacity.{key} must be finite and non-negative"
301
+ )
302
+ capacity.append((key, amount))
303
+
304
+ max_concurrent = raw.get("max_concurrent", 1)
305
+ if (
306
+ isinstance(max_concurrent, bool)
307
+ or not isinstance(max_concurrent, int)
308
+ or not 1 <= max_concurrent <= 1024
309
+ ):
310
+ raise KeyringError(f"{prefix}.max_concurrent must be 1..1024")
311
+ grants.append(WorkerGrant(
312
+ node_id=node_id,
313
+ capabilities=capabilities,
314
+ capacity=tuple(sorted(capacity)),
315
+ max_concurrent=max_concurrent,
316
+ job_types=job_types,
317
+ ))
318
+
319
+ worker_scopes = {
320
+ "workers:register", "workers:claim", "workers:lifecycle",
321
+ }
322
+ if grants and "*" not in scopes and not (worker_scopes & set(scopes)):
323
+ raise KeyringError(f"{field} requires at least one workers:* scope")
324
+ if (
325
+ grants
326
+ and "*" not in scopes
327
+ and "workers:claim" in scopes
328
+ and "workers:lifecycle" not in scopes
329
+ ):
330
+ raise KeyringError(
331
+ f"{field} with workers:claim also requires workers:lifecycle"
332
+ )
333
+ return tuple(grants)
334
+
335
+
336
+ def _parse_principal(raw: Any, *, index: int) -> Principal:
337
+ if not isinstance(raw, Mapping):
338
+ raise KeyringError(f"principals[{index}] must be an object")
339
+ prefix = f"principals[{index}]"
340
+ raw_principal_id = raw.get("principal")
341
+ principal_id = _clean_string(
342
+ raw_principal_id, field=f"{prefix}.principal", required=True
343
+ )
344
+ assert principal_id is not None
345
+ if raw_principal_id != principal_id or not _PRINCIPAL_RE.fullmatch(principal_id):
346
+ raise KeyringError(
347
+ f"{prefix}.principal must be a canonical principal ID of at most "
348
+ "128 characters"
349
+ )
350
+ if principal_id in _RESERVED_PRINCIPALS:
351
+ raise KeyringError(f"{prefix}.principal uses a reserved telemetry identity")
352
+
353
+ scopes = _string_set(raw.get("scopes"), field=f"{prefix}.scopes")
354
+ for scope in scopes:
355
+ if not _SCOPE_RE.fullmatch(scope):
356
+ raise KeyringError(f"{prefix}.scopes contains invalid scope {scope!r}")
357
+ allow_unscoped_api = raw.get("allow_unscoped_api", True)
358
+ if not isinstance(allow_unscoped_api, bool):
359
+ raise KeyringError(f"{prefix}.allow_unscoped_api must be a boolean")
360
+
361
+ raw_viewer = raw.get("viewer_person_id")
362
+ viewer = (
363
+ _person_id(raw_viewer, field=f"{prefix}.viewer_person_id")
364
+ if raw_viewer is not None
365
+ else None
366
+ )
367
+ person_ids = _person_id_set(
368
+ raw.get("person_ids"), field=f"{prefix}.person_ids",
369
+ )
370
+ audiences = _string_set(raw.get("audiences"), field=f"{prefix}.audiences")
371
+ unknown_audiences = audiences - _AUDIENCES
372
+ if unknown_audiences:
373
+ raise KeyringError(
374
+ f"{prefix}.audiences contains unknown lanes: "
375
+ f"{', '.join(sorted(unknown_audiences))}"
376
+ )
377
+ if "viewer" in audiences and not viewer:
378
+ raise KeyringError(f"{prefix}.audiences grants viewer without viewer_person_id")
379
+ if principal_id == _GOVERNED_ACTION_PRINCIPAL and (
380
+ scopes != _GOVERNED_ACTION_SCOPES
381
+ or allow_unscoped_api is not False
382
+ or not viewer
383
+ or viewer not in person_ids
384
+ or audiences != frozenset({"owner"})
385
+ ):
386
+ raise KeyringError(
387
+ f"{prefix} must be the exact owner-bound governed-action role"
388
+ )
389
+
390
+ worker_grants = _parse_worker_grants(
391
+ raw.get("worker_grants"), field=f"{prefix}.worker_grants", scopes=scopes,
392
+ )
393
+ if "workers:attest" in scopes:
394
+ execution_scopes = scopes & {
395
+ "workers:register", "workers:claim", "workers:lifecycle", "*",
396
+ }
397
+ if worker_grants or execution_scopes:
398
+ raise KeyringError(
399
+ f"{prefix}.workers:attest requires a verifier-only principal "
400
+ "with no worker grants or execution scopes"
401
+ )
402
+
403
+ turn_ingress_platforms_explicit = "turn_ingress_platforms" in raw
404
+ turn_ingress_platforms = _string_set(
405
+ raw.get("turn_ingress_platforms"),
406
+ field=f"{prefix}.turn_ingress_platforms",
407
+ )
408
+ for platform in turn_ingress_platforms:
409
+ if not _PLATFORM_RE.fullmatch(platform) or "*" in platform:
410
+ raise KeyringError(
411
+ f"{prefix}.turn_ingress_platforms contains invalid platform"
412
+ )
413
+ if (
414
+ turn_ingress_platforms
415
+ and "turns:write" not in scopes
416
+ and "*" not in scopes
417
+ ):
418
+ raise KeyringError(
419
+ f"{prefix}.turn_ingress_platforms requires turns:write"
420
+ )
421
+
422
+ attested_policy = raw.get("attested_contact_grants")
423
+ attested_platforms: frozenset[str] = frozenset()
424
+ attested_limit = 0
425
+ if attested_policy is not None:
426
+ if not isinstance(attested_policy, Mapping):
427
+ raise KeyringError(f"{prefix}.attested_contact_grants must be an object")
428
+ unknown = set(attested_policy) - {"platforms", "max_person_ids"}
429
+ if unknown:
430
+ raise KeyringError(
431
+ f"{prefix}.attested_contact_grants contains unsupported fields"
432
+ )
433
+ attested_platforms = _string_set(
434
+ attested_policy.get("platforms"),
435
+ field=f"{prefix}.attested_contact_grants.platforms",
436
+ )
437
+ if not attested_platforms:
438
+ raise KeyringError(
439
+ f"{prefix}.attested_contact_grants.platforms must not be empty"
440
+ )
441
+ for platform in attested_platforms:
442
+ if not _PLATFORM_RE.fullmatch(platform) or "*" in platform:
443
+ raise KeyringError(
444
+ f"{prefix}.attested_contact_grants contains invalid platform"
445
+ )
446
+ raw_limit = attested_policy.get("max_person_ids", 512)
447
+ if (
448
+ isinstance(raw_limit, bool)
449
+ or not isinstance(raw_limit, int)
450
+ or not 1 <= raw_limit <= 4096
451
+ ):
452
+ raise KeyringError(
453
+ f"{prefix}.attested_contact_grants.max_person_ids must be 1..4096"
454
+ )
455
+ attested_limit = raw_limit
456
+ if "turns:resolve-sender" not in scopes and "*" not in scopes:
457
+ raise KeyringError(
458
+ f"{prefix}.attested_contact_grants requires turns:resolve-sender"
459
+ )
460
+ # Compatibility is deliberately server-owned and default-off. An
461
+ # existing turns writer that omitted the new role derives it only
462
+ # from the already validated static contact-attestation policy. An
463
+ # explicit role (including an explicit empty role) is authoritative.
464
+ if (
465
+ not turn_ingress_platforms_explicit
466
+ and ("turns:write" in scopes or "*" in scopes)
467
+ ):
468
+ turn_ingress_platforms = attested_platforms
469
+ if (
470
+ turn_ingress_platforms_explicit
471
+ and not attested_platforms.issubset(turn_ingress_platforms)
472
+ ):
473
+ raise KeyringError(
474
+ f"{prefix}.attested_contact_grants.platforms must be a subset "
475
+ "of turn_ingress_platforms"
476
+ )
477
+
478
+ raw_credentials = raw.get("credentials")
479
+ if not isinstance(raw_credentials, list) or not raw_credentials:
480
+ raise KeyringError(f"{prefix}.credentials must be a non-empty JSON array")
481
+ credentials: list[Credential] = []
482
+ credential_ids: set[str] = set()
483
+ for cred_index, raw_credential in enumerate(raw_credentials):
484
+ cp = f"{prefix}.credentials[{cred_index}]"
485
+ if not isinstance(raw_credential, Mapping):
486
+ raise KeyringError(f"{cp} must be an object")
487
+ raw_credential_id = raw_credential.get("id")
488
+ credential_id = _clean_string(
489
+ raw_credential_id, field=f"{cp}.id", required=True
490
+ )
491
+ secret = _clean_string(
492
+ raw_credential.get("secret"), field=f"{cp}.secret", required=True
493
+ )
494
+ assert credential_id is not None and secret is not None
495
+ if (
496
+ raw_credential_id != credential_id
497
+ or not _CREDENTIAL_ID_RE.fullmatch(credential_id)
498
+ ):
499
+ raise KeyringError(
500
+ f"{cp}.id must be a canonical credential ID of at most 192 characters"
501
+ )
502
+ if credential_id in credential_ids:
503
+ raise KeyringError(f"duplicate credential id {credential_id!r} in {principal_id}")
504
+ credential_ids.add(credential_id)
505
+ credential_status = _parse_status(
506
+ raw_credential.get("status"), field=f"{cp}.status"
507
+ )
508
+ credential_accept_until = _parse_time(
509
+ raw_credential.get("accept_until"), field=f"{cp}.accept_until"
510
+ )
511
+ if credential_status == "retiring" and credential_accept_until is None:
512
+ raise KeyringError(f"{cp}.retiring credential requires accept_until")
513
+ credentials.append(Credential(
514
+ credential_id=credential_id,
515
+ secret=secret,
516
+ status=credential_status,
517
+ accept_until=credential_accept_until,
518
+ ))
519
+
520
+ principal_status = _parse_status(raw.get("status"), field=f"{prefix}.status")
521
+ principal_accept_until = _parse_time(
522
+ raw.get("accept_until"), field=f"{prefix}.accept_until"
523
+ )
524
+ if principal_status == "retiring" and principal_accept_until is None:
525
+ raise KeyringError(f"{prefix}.retiring principal requires accept_until")
526
+ return Principal(
527
+ principal_id=principal_id,
528
+ status=principal_status,
529
+ accept_until=principal_accept_until,
530
+ scopes=scopes,
531
+ allow_unscoped_api=allow_unscoped_api,
532
+ viewer_person_id=viewer,
533
+ person_ids=person_ids,
534
+ audiences=audiences,
535
+ turn_ingress_platforms=turn_ingress_platforms,
536
+ attested_contact_platforms=attested_platforms,
537
+ attested_contact_limit=attested_limit,
538
+ worker_grants=worker_grants,
539
+ credentials=tuple(credentials),
540
+ )
541
+
542
+
543
+ def load_keyring(path: Path) -> tuple[Principal, ...]:
544
+ """Load one private keyring, rejecting group/world-readable files."""
545
+
546
+ info = path.stat()
547
+ if not stat.S_ISREG(info.st_mode):
548
+ raise KeyringError("keyring must be a regular file")
549
+ mode = stat.S_IMODE(info.st_mode)
550
+ if mode & 0o077:
551
+ raise KeyringError(
552
+ f"keyring permissions must be private (chmod 600); found {mode:04o}"
553
+ )
554
+ try:
555
+ raw = json.loads(path.read_text(encoding="utf-8"))
556
+ except json.JSONDecodeError as exc:
557
+ raise KeyringError("keyring is not valid JSON") from exc
558
+ if not isinstance(raw, Mapping) or raw.get("version") != 1:
559
+ raise KeyringError("keyring version must be 1")
560
+ raw_principals = raw.get("principals")
561
+ if not isinstance(raw_principals, list):
562
+ raise KeyringError("keyring principals must be a JSON array")
563
+
564
+ principals = tuple(
565
+ _parse_principal(value, index=index)
566
+ for index, value in enumerate(raw_principals)
567
+ )
568
+ principal_ids: set[str] = set()
569
+ secrets: set[str] = set()
570
+ worker_nodes: set[str] = set()
571
+ for principal in principals:
572
+ if principal.principal_id in principal_ids:
573
+ raise KeyringError(f"duplicate principal {principal.principal_id!r}")
574
+ principal_ids.add(principal.principal_id)
575
+ for grant in principal.worker_grants:
576
+ if grant.node_id in worker_nodes:
577
+ raise KeyringError(
578
+ f"worker node {grant.node_id!r} is granted to multiple principals"
579
+ )
580
+ worker_nodes.add(grant.node_id)
581
+ for credential in principal.credentials:
582
+ if credential.secret in secrets:
583
+ raise KeyringError("credential secrets must be unique across the keyring")
584
+ secrets.add(credential.secret)
585
+ return principals
586
+
587
+
588
+ class KeyringLoader:
589
+ """Reload a keyring when its identity, contents, timestamps, or mode change.
590
+
591
+ An invalid replacement disables scoped credentials until a valid private
592
+ file is installed. The separately configured legacy key remains usable,
593
+ which makes migration failures recoverable without accepting stale revoked
594
+ scoped keys.
595
+ """
596
+
597
+ def __init__(self, path: str | os.PathLike[str] | None) -> None:
598
+ self.path = Path(path).expanduser() if path else None
599
+ self._signature: tuple[int, int, int, int, int] | tuple[str] | None = None
600
+ self._principals: tuple[Principal, ...] = ()
601
+ self._error: str | None = None
602
+ self._lock = threading.RLock()
603
+
604
+ @property
605
+ def configured(self) -> bool:
606
+ return self.path is not None
607
+
608
+ @property
609
+ def error(self) -> str | None:
610
+ self._reload_if_needed()
611
+ return self._error
612
+
613
+ def _current_signature(self) -> tuple[int, int, int, int, int] | tuple[str]:
614
+ assert self.path is not None
615
+ try:
616
+ info = self.path.stat()
617
+ except OSError:
618
+ return ("missing",)
619
+ return (
620
+ info.st_ino,
621
+ info.st_size,
622
+ info.st_mtime_ns,
623
+ info.st_ctime_ns,
624
+ stat.S_IMODE(info.st_mode),
625
+ )
626
+
627
+ def _reload_if_needed(self) -> None:
628
+ if self.path is None:
629
+ return
630
+ signature = self._current_signature()
631
+ with self._lock:
632
+ if signature == self._signature:
633
+ return
634
+ self._signature = signature
635
+ try:
636
+ self._principals = load_keyring(self.path)
637
+ self._error = None
638
+ logger.info(
639
+ "Loaded %d scoped API principals from %s",
640
+ len(self._principals), self.path,
641
+ )
642
+ except (OSError, KeyringError) as exc:
643
+ self._principals = ()
644
+ self._error = str(exc)
645
+ logger.error("Scoped API keyring unavailable: %s", exc)
646
+
647
+ def authenticate(self, token: str) -> AuthenticatedCredential | None:
648
+ self._reload_if_needed()
649
+ match: AuthenticatedCredential | None = None
650
+ # Always inspect every configured secret rather than returning on the
651
+ # first comparison. The keyring is deliberately small and this avoids
652
+ # making credential order an obvious timing oracle.
653
+ for principal in self._principals:
654
+ for credential in principal.credentials:
655
+ if hmac.compare_digest(
656
+ token.encode("utf-8"), credential.secret.encode("utf-8")
657
+ ):
658
+ match = AuthenticatedCredential(principal, credential)
659
+ return match
660
+
661
+ def status(self) -> dict[str, Any]:
662
+ """Return loader health and counts without principals or key material."""
663
+
664
+ self._reload_if_needed()
665
+ with self._lock:
666
+ return {
667
+ "configured": self.configured,
668
+ "available": self.configured and self._error is None,
669
+ "error": self._error,
670
+ "principal_count": len(self._principals),
671
+ "credential_count": sum(
672
+ len(principal.credentials) for principal in self._principals
673
+ ),
674
+ }
675
+
676
+
677
+ def _audience_person_ids() -> dict[str, str]:
678
+ owner = (
679
+ os.environ.get("COLONY_OWNER_PERSON_ID", "").strip()
680
+ or os.environ.get("COLONY_OWNER_CONTACT_ID", "").strip()
681
+ or "owner"
682
+ )
683
+ return {
684
+ "owner": owner,
685
+ "shared": os.environ.get("COLONY_SHARED_PERSON_ID", "shared").strip() or "shared",
686
+ "global": os.environ.get("COLONY_GLOBAL_PERSON_ID", "global").strip() or "global",
687
+ }
688
+
689
+
690
+ def scoped_authority(
691
+ match: AuthenticatedCredential,
692
+ granted_person_ids: frozenset[str] = frozenset(),
693
+ ) -> RequestAuthority:
694
+ principal = match.principal
695
+ mapped = _audience_person_ids()
696
+ static_person_ids = set(principal.person_ids)
697
+ if principal.viewer_person_id:
698
+ static_person_ids.add(principal.viewer_person_id)
699
+ for audience in principal.audiences:
700
+ if audience in mapped:
701
+ static_person_ids.add(mapped[audience])
702
+ person_ids = set(static_person_ids)
703
+ if principal.attested_contact_platforms:
704
+ person_ids.update(granted_person_ids)
705
+ return RequestAuthority(
706
+ principal_id=principal.principal_id,
707
+ credential_id=match.credential.credential_id,
708
+ scopes=principal.scopes,
709
+ allow_unscoped_api=principal.allow_unscoped_api,
710
+ viewer_person_id=principal.viewer_person_id,
711
+ person_ids=frozenset(person_ids),
712
+ audiences=principal.audiences,
713
+ authenticated=True,
714
+ static_person_ids=frozenset(static_person_ids),
715
+ turn_ingress_platforms=principal.turn_ingress_platforms,
716
+ attested_contact_platforms=principal.attested_contact_platforms,
717
+ attested_contact_limit=principal.attested_contact_limit,
718
+ worker_grants=principal.worker_grants,
719
+ )
720
+
721
+
722
+ def legacy_authority() -> RequestAuthority:
723
+ return RequestAuthority(
724
+ principal_id="legacy",
725
+ credential_id="COLONY_API_KEY",
726
+ scopes=frozenset({"*"}),
727
+ viewer_person_id=None,
728
+ person_ids=frozenset(),
729
+ audiences=_AUDIENCES,
730
+ authenticated=True,
731
+ legacy=True,
732
+ )
733
+
734
+
735
+ def anonymous_authority() -> RequestAuthority:
736
+ return RequestAuthority(
737
+ principal_id="anonymous-dev",
738
+ credential_id=None,
739
+ scopes=frozenset(),
740
+ viewer_person_id=None,
741
+ person_ids=frozenset(),
742
+ audiences=frozenset(),
743
+ authenticated=False,
744
+ anonymous=True,
745
+ )
746
+
747
+
748
+ def record_attested_contact_grant(
749
+ request: Request | None,
750
+ *,
751
+ platform: str,
752
+ person_id: str,
753
+ ) -> bool:
754
+ """Project one ParticipantResolver result into exact principal authority.
755
+
756
+ This function is called only after the server-side resolver has returned a
757
+ concrete contact. It never reads a body-asserted contact ID and never adds
758
+ an audience lane or scope.
759
+ """
760
+
761
+ if request is None:
762
+ return False
763
+ authority = request_authority(request)
764
+ platform = (platform or "").strip().lower()
765
+ person_id = (person_id or "").strip()
766
+ if (
767
+ authority.legacy
768
+ or authority.anonymous
769
+ or not authority.authenticated
770
+ or not authority.has_scope("turns:resolve-sender")
771
+ or platform not in authority.attested_contact_platforms
772
+ or authority.attested_contact_limit <= 0
773
+ or not person_id
774
+ ):
775
+ return False
776
+ registry = getattr(request.state, "colony_contact_grants", None)
777
+ if registry is None:
778
+ return False
779
+ try:
780
+ return bool(registry.grant(
781
+ authority.principal_id,
782
+ person_id,
783
+ max_person_ids=authority.attested_contact_limit,
784
+ ))
785
+ except Exception:
786
+ logger.warning(
787
+ "Failed to persist server-attested contact grant for principal %s",
788
+ authority.principal_id,
789
+ exc_info=True,
790
+ )
791
+ return False
792
+
793
+
794
+ def _authority_error(code: str, message: str) -> HTTPException:
795
+ return HTTPException(status_code=403, detail={"code": code, "message": message})
796
+
797
+
798
+ def request_authority(request: Request | None) -> RequestAuthority:
799
+ if request is None:
800
+ # Direct in-process router calls predate HTTP authority middleware and
801
+ # are trusted like other internal method calls. Actual HTTP dev-mode
802
+ # requests always carry the explicit anonymous authority below.
803
+ return legacy_authority()
804
+ value = getattr(request.state, "colony_authority", None)
805
+ if isinstance(value, RequestAuthority):
806
+ return value
807
+ # Routers are mounted without middleware in a number of focused unit tests.
808
+ # Treat that path exactly like loopback dev mode, never like owner authority.
809
+ return anonymous_authority()
810
+
811
+
812
+ def resolve_request_person(
813
+ request: Request | None,
814
+ *,
815
+ claimed_person_id: str | None = None,
816
+ context_person_id: str | None = None,
817
+ audience: str | None = None,
818
+ ) -> str | None:
819
+ """Resolve one exact person/audience lane from authenticated authority."""
820
+
821
+ authority = request_authority(request)
822
+ claimed = (claimed_person_id or "").strip() or None
823
+ context = (context_person_id or "").strip() or None
824
+ if claimed and context and claimed != context:
825
+ raise _authority_error(
826
+ "person_scope_conflict",
827
+ "person_id and context.contact_id must identify the same person",
828
+ )
829
+
830
+ audience = (audience or "").strip().lower() or None
831
+ if audience and audience not in _AUDIENCES:
832
+ raise _authority_error("invalid_audience", "unknown audience lane")
833
+
834
+ mapped = _audience_person_ids()
835
+ lane_person: str | None = None
836
+ if audience:
837
+ if authority.anonymous:
838
+ raise _authority_error(
839
+ "audience_not_granted",
840
+ "anonymous dev mode cannot select authenticated audience lanes",
841
+ )
842
+ if not authority.legacy and audience not in authority.audiences:
843
+ raise _authority_error(
844
+ "audience_not_granted",
845
+ f"principal {authority.principal_id!r} is not granted audience {audience!r}",
846
+ )
847
+ lane_person = (
848
+ authority.viewer_person_id if audience == "viewer" else mapped[audience]
849
+ )
850
+ if not lane_person:
851
+ raise _authority_error(
852
+ "audience_unbound", "the selected audience has no person binding"
853
+ )
854
+ body_person = claimed or context
855
+ if body_person and body_person != lane_person:
856
+ raise _authority_error(
857
+ "person_scope_conflict",
858
+ "body person does not match the selected audience lane",
859
+ )
860
+
861
+ target = lane_person or claimed or context
862
+ if authority.legacy:
863
+ # Compatibility carve-out: the old global bearer keeps its historical
864
+ # body-selected/no-filter behavior until all consumers are migrated.
865
+ return target
866
+
867
+ if authority.anonymous:
868
+ target = target or (
869
+ os.environ.get("COLONY_DEV_PERSON_ID", "dev-anonymous").strip()
870
+ or "dev-anonymous"
871
+ )
872
+ reserved = set(mapped.values())
873
+ if target in reserved:
874
+ raise _authority_error(
875
+ "reserved_authority_required",
876
+ "owner, shared, and global memory require an authenticated principal",
877
+ )
878
+ return target
879
+
880
+ target = target or authority.viewer_person_id
881
+ if not target:
882
+ raise _authority_error(
883
+ "person_binding_required",
884
+ "this scoped principal has no default viewer/person binding",
885
+ )
886
+ if target not in authority.person_ids:
887
+ raise _authority_error(
888
+ "person_scope_not_granted",
889
+ f"principal {authority.principal_id!r} is not granted person {target!r}",
890
+ )
891
+ return target
892
+
893
+
894
+ def resolve_turn_person(
895
+ request: Request | None,
896
+ *,
897
+ context_person_id: str | None,
898
+ has_sender: bool,
899
+ ) -> str | None:
900
+ """Authorize a turn's initial contact before idempotency reservation.
901
+
902
+ Most principals must submit their exact bound viewer/person. A transport
903
+ adapter may additionally receive ``turns:resolve-sender``; for a structured
904
+ sender envelope Colony then discards the body contact claim, starts from the
905
+ principal's viewer binding, and lets ``ParticipantResolver`` establish the
906
+ human contact server-side inside turn processing.
907
+ """
908
+
909
+ authority = request_authority(request)
910
+ if (
911
+ has_sender
912
+ and not authority.legacy
913
+ and not authority.anonymous
914
+ and authority.has_scope("turns:resolve-sender")
915
+ ):
916
+ if not authority.viewer_person_id:
917
+ raise _authority_error(
918
+ "person_binding_required",
919
+ "sender-resolving principals need a default viewer/person binding",
920
+ )
921
+ return authority.viewer_person_id
922
+ return resolve_request_person(
923
+ request,
924
+ context_person_id=context_person_id,
925
+ )
926
+
927
+
928
+ def query_person_is_granted(authority: RequestAuthority, person_id: str) -> bool:
929
+ """Validate legacy query-string person selectors without trusting them."""
930
+
931
+ target = (person_id or "").strip()
932
+ if not target or authority.legacy:
933
+ return True
934
+ if authority.anonymous:
935
+ return target not in set(_audience_person_ids().values())
936
+ return target in authority.person_ids
937
+
938
+
939
+ def worker_authority_mode() -> str:
940
+ """Return the worker HTTP authority migration posture.
941
+
942
+ ``shadow`` preserves legacy/global-bearer consumers while recording the
943
+ exact posture that enforcement would require. ``enforce`` accepts only a
944
+ scoped principal with an exact worker grant. Unknown values are exposed
945
+ as ``invalid`` so handlers return an operator-fixable 503 rather than
946
+ silently choosing a weaker policy.
947
+ """
948
+
949
+ value = os.environ.get("COLONY_WORKER_AUTHORITY_MODE", "shadow").strip().lower()
950
+ return value if value in {"shadow", "enforce"} else "invalid"
951
+
952
+
953
+ def compatible_scopes(method: str, path: str) -> frozenset[str]:
954
+ """Return deliberate legacy scope aliases for one exact route.
955
+
956
+ Compatibility never makes ``api:access`` a global parent of focused
957
+ scopes. Middleware also applies ``allow_unscoped_api`` before accepting
958
+ this route-local alias.
959
+ """
960
+
961
+ if (method.upper(), path) in WORK_READ_SURFACE_V1 or (method.upper(), path) in {
962
+ ("POST", "/v1/host/learning/correction"),
963
+ ("POST", "/v1/host/response-guard/check"),
964
+ }:
965
+ return _API_ACCESS_COMPATIBILITY
966
+ return _NO_COMPATIBILITY_SCOPES
967
+
968
+
969
+ def required_scope(method: str, path: str) -> str:
970
+ """Return the exact scope required for a scoped credential."""
971
+
972
+ key = (method.upper(), path)
973
+ if (method.upper() == 'POST' and path in {
974
+ '/v1/host/transport/ingress/admit', '/v1/host/transport/ingress/handoff',
975
+ '/v1/host/transport/ingress/coverage'} or
976
+ method.upper() == 'GET' and path == '/v1/host/transport/ingress/receipts'):
977
+ return 'transport:write'
978
+ if method.upper() == 'GET' and re.fullmatch(r'/v1/host/initiative-work(?:/[^/]+)?', path):
979
+ return 'context:read'
980
+ if method.upper() == 'POST' and re.fullmatch(r'/v1/host/initiative-work/[^/]+/(?:native-task|observe)', path):
981
+ return 'turns:write'
982
+ if method.upper() == 'POST' and re.fullmatch(r'/v1/host/temporal-followups/[^/]+/(?:verify-plan|check-plan)', path):
983
+ return 'transport:write'
984
+ if method.upper() == 'GET' and re.fullmatch(r'/v1/host/temporal-followups(?:/[^/]+)?', path):
985
+ return 'context:read'
986
+ if method.upper() == 'POST' and re.fullmatch(r'/v1/host/temporal-followups(?:/[^/]+/(?:change|native-task|prepare|observe|bind-plan))?', path):
987
+ return 'turns:write'
988
+ if method.upper() == "POST" and path.startswith("/v1/host/commitments/") and path.endswith("/work"):
989
+ return "turns:write"
990
+ if (method.upper() == 'POST'
991
+ and re.fullmatch(r'/v1/host/commitments/(?:local-draft|[^/]+/local-draft|local-work/next|local-work/[^/]+/(?:finish|native-task|native-run))', path)):
992
+ return 'turns:write'
993
+ if method.upper() == 'GET' and re.fullmatch(r'/v1/host/commitments/local-work/[^/]+', path):
994
+ return 'turns:write'
995
+ if key in WORK_READ_SURFACE_V1:
996
+ return "work:read"
997
+ if method.upper() == "GET" and path.startswith("/v1/host/memory/sources/assets/"):
998
+ return "memory:read"
999
+ if method.upper() == "POST" and path in {
1000
+ "/v1/host/contact-policy/standing",
1001
+ "/v1/host/contact-policy/provision",
1002
+ }:
1003
+ return "contacts:policy-write"
1004
+ if path.startswith("/v1/host/actions/"):
1005
+ if method.upper() == "PUT":
1006
+ return "actions:execute"
1007
+ if method.upper() == "GET":
1008
+ return "actions:verify"
1009
+ worker_mode = worker_authority_mode()
1010
+ if (
1011
+ method.upper() == "POST"
1012
+ and path == "/v1/host/queue/work/reconciliations"
1013
+ ):
1014
+ return "workers:attest"
1015
+ if (
1016
+ path == "/v1/host/queue/work"
1017
+ or path.startswith("/v1/host/queue/work/")
1018
+ ):
1019
+ return "work:read" if method.upper() == "GET" else "work:control"
1020
+ if (
1021
+ path.startswith("/v1/host/queue/workers/")
1022
+ and "/controls" in path
1023
+ ):
1024
+ return "workers:lifecycle"
1025
+ if (
1026
+ method.upper() == "POST"
1027
+ and path.startswith("/v1/host/queue/attestations/jobs/")
1028
+ ):
1029
+ return "workers:attest"
1030
+ if worker_mode in {"enforce", "invalid"} and method.upper() == "POST":
1031
+ if path == "/v1/host/queue/workers/register" or (
1032
+ path.startswith("/v1/host/queue/workers/")
1033
+ and path.endswith("/deregister")
1034
+ ):
1035
+ return "workers:register"
1036
+ if path == "/v1/host/queue/jobs/claim":
1037
+ return "workers:claim"
1038
+ if (
1039
+ path.startswith("/v1/host/queue/workers/")
1040
+ and path.endswith("/heartbeat")
1041
+ ) or (
1042
+ path.startswith("/v1/host/queue/jobs/")
1043
+ and path.endswith((
1044
+ "/start", "/complete", "/fail", "/heartbeat", "/release",
1045
+ ))
1046
+ ):
1047
+ return "workers:lifecycle"
1048
+ if (
1049
+ method.upper() == "GET"
1050
+ and path.startswith("/v1/host/queue/inspection/jobs/")
1051
+ ):
1052
+ return "workers:inspect"
1053
+ # Approval routes always have stable exact scopes so restricted scoped
1054
+ # principals can canary them in shadow without gaining generic api:access
1055
+ # fallback. Shadow may still accept the legacy bearer; enforce additionally
1056
+ # requires an exact scoped principal in middleware.
1057
+ if path.startswith("/v1/host/queue/approvals/"):
1058
+ if method.upper() == "GET":
1059
+ return "approvals:read"
1060
+ if method.upper() == "DELETE":
1061
+ return "approvals:manage"
1062
+ return "approvals:decide"
1063
+ if (
1064
+ method.upper() == "GET"
1065
+ and path == "/v1/host/queue/jobs/blocked"
1066
+ ):
1067
+ return "approvals:read"
1068
+ if (
1069
+ path.startswith("/v1/host/queue/jobs/")
1070
+ and path.endswith(("/approve", "/reject"))
1071
+ ):
1072
+ return "approvals:decide"
1073
+ if (
1074
+ method.upper() == "POST"
1075
+ and path.startswith("/v1/host/self/tools/")
1076
+ and path.endswith("/graduate")
1077
+ ):
1078
+ return "toolsmith:graduate"
1079
+ if (
1080
+ method.upper() == "POST"
1081
+ and path.startswith("/v1/host/self/tools/")
1082
+ and path.endswith("/shadow-compare")
1083
+ ):
1084
+ return "toolsmith:evaluate"
1085
+ if method.upper() == "POST" and path == "/v1/host/sandbox/run":
1086
+ return "sandbox:execute"
1087
+ if (
1088
+ method.upper() == "POST"
1089
+ and path.startswith("/v1/host/cognition/charters/")
1090
+ and path.endswith(("/request-activation", "/request-revocation"))
1091
+ ):
1092
+ return "charter:request"
1093
+ if (
1094
+ method.upper() == "POST"
1095
+ and path.startswith("/v1/host/cognition/charters/")
1096
+ and path.endswith("/ratify")
1097
+ ):
1098
+ return "charter:ratify"
1099
+ if path.startswith(
1100
+ "/v1/host/cognition/charter-transition-approvals"
1101
+ ):
1102
+ if method.upper() == "GET":
1103
+ return "charter:approval-read"
1104
+ if method.upper() == "POST" and path.endswith("/decision"):
1105
+ return "charter:approval-decide"
1106
+ return "api:access"
1107
+ exact = {
1108
+ ("GET", "/v1/host/admin/auth/status"): "auth:admin",
1109
+ ("GET", "/v1/host/contact-policy"): "contacts:policy-read",
1110
+ ("POST", "/v1/host/response-guard/check"): "response-guard:check",
1111
+ ("GET", "/v1/host/queue/contract"): "workers:contract",
1112
+ ("POST", "/v1/host/memory/read"): "memory:read",
1113
+ ("POST", "/v1/host/memory/search"): "memory:search",
1114
+ ("POST", "/v1/host/memory/write"): "memory:write",
1115
+ ("POST", "/v1/host/memory/sources/forget"): "memory:write",
1116
+ ("POST", "/v1/host/memory/sources/annotations"): "memory:write",
1117
+ ("GET", "/v1/host/memory/sources/erasures"): "turns:write",
1118
+ ("POST", "/v1/host/memory/sources/erasures"): "turns:write",
1119
+ ("GET", "/v1/host/memory/sources/claims/status"): "memory:read",
1120
+ ("GET", "/v1/host/preferences"): "context:read",
1121
+ ("GET", "/v1/host/self"): "context:read",
1122
+ ("POST", "/v1/host/transport/observe"): "transport:write",
1123
+ ("GET", "/v1/host/social/appraisals"): "context:read",
1124
+ ("POST", "/v1/host/social/appraisals/correct"): "turns:write",
1125
+ ("GET", "/v1/host/social/contacts"): "context:read",
1126
+ ("POST", "/v1/host/social/contacts/correct-identity"): "turns:write",
1127
+ ("POST", "/v1/host/preferences/learn"): "memory:write",
1128
+ ("POST", "/v1/host/learning/correction"): "memory:write",
1129
+ ("POST", "/v1/host/context/assemble"): "context:read",
1130
+ ("GET", "/v1/host/executions"): "context:read",
1131
+ ("POST", "/v1/host/executions/observe"): "turns:write",
1132
+ ("GET", "/v1/host/context/projection-readiness"): "context:read",
1133
+ ("POST", "/v1/host/context/enriched"): "context:read",
1134
+ ("GET", "/v1/host/context/temporal"): "context:read",
1135
+ ("POST", "/v1/host/turns/sync"): "turns:write",
1136
+ ("GET", "/v1/host/events/replay"): "events:read",
1137
+ ("GET", "/v1/host/self/workspace"): "cognition:read",
1138
+ ("GET", "/v1/host/self/benchmark"): "cognition:benchmark-read",
1139
+ ("POST", "/v1/host/self/benchmark/samples"):
1140
+ "cognition:benchmark-manage",
1141
+ ("POST", "/v1/host/self/benchmark/recall-probe"):
1142
+ "cognition:benchmark-manage",
1143
+ ("POST", "/v1/host/self/benchmark/compute"):
1144
+ "cognition:benchmark-manage",
1145
+ ("GET", "/v1/host/self/experiments"):
1146
+ "cognition:experiment-read",
1147
+ ("POST", "/v1/host/self/experiments"):
1148
+ "cognition:experiment-manage",
1149
+ ("GET", "/v1/host/self/params"): "cognition:experiment-read",
1150
+ ("GET", "/v1/host/cognition/cpi"): "cognition:benchmark-read",
1151
+ ("POST", "/v1/host/cognition/cycle"):
1152
+ "cognition:benchmark-manage",
1153
+ ("GET", "/v1/host/self/situation"): "cognition:read",
1154
+ ("GET", "/v1/host/autonomy/posture"): "cognition:read",
1155
+ ("GET", "/v1/host/autonomy/schedule"): "cognition:read",
1156
+ ("GET", "/v1/host/autonomy/status"): "cognition:read",
1157
+ ("GET", "/v1/host/cognition/evidence"): "cognition:read",
1158
+ ("GET", "/v1/host/cognition/drives"): "drives:read",
1159
+ ("GET", "/v1/host/cognition/charters"): "charter:read",
1160
+ ("GET", "/v1/host/cognition/rankings"): "drives:read",
1161
+ ("GET", "/v1/host/cognition/spine"): "cognition:read",
1162
+ ("POST", "/v1/host/cognition/events"): "cognition:events-ingest",
1163
+ ("GET", "/v1/host/proposals"): "cognition:read",
1164
+ ("GET", "/v1/host/tom/p8/status"): "tom:read",
1165
+ ("GET", "/v1/host/tom/p8/deck"): "tom:read",
1166
+ ("GET", "/v1/host/tom2/report"): "tom:read",
1167
+ ("POST", "/v1/host/cognition/drives"): "drives:propose",
1168
+ ("POST", "/v1/host/cognition/drive-signals"): "drives:signal",
1169
+ ("POST", "/v1/host/cognition/charters"): "charter:propose",
1170
+ }
1171
+ if key in exact:
1172
+ return exact[key]
1173
+ if method.upper() == "PUT" and path.startswith("/v2/host/turns/"):
1174
+ return "turns:write"
1175
+ if (
1176
+ method.upper() == "POST"
1177
+ and path.startswith("/v1/host/cognition/concerns/")
1178
+ and path.endswith("/promote")
1179
+ ):
1180
+ return "cognition:manage"
1181
+ if (
1182
+ method.upper() == "POST"
1183
+ and path.startswith("/v1/host/cognition/goals/")
1184
+ and path.endswith("/promote")
1185
+ ):
1186
+ return "cognition:manage"
1187
+ if (
1188
+ method.upper() == "GET"
1189
+ and path.startswith("/v1/host/self/workspace/")
1190
+ and path.endswith("/resolution")
1191
+ ):
1192
+ return "cognition:read"
1193
+ if (
1194
+ method.upper() == "POST"
1195
+ and path.startswith("/v1/host/self/workspace/")
1196
+ and path.endswith("/resolve")
1197
+ ):
1198
+ return "cognition:manage"
1199
+ if path.startswith("/v1/host/self/experiments/"):
1200
+ if method.upper() == "GET":
1201
+ return "cognition:experiment-read"
1202
+ return "cognition:experiment-manage"
1203
+ return "api:access"