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,1731 @@
1
+ """Evidence-derived, scoped present-tense situation spine (Mind M3/P6).
2
+
3
+ The situation spine is a reducer, not a second world model. It accepts only
4
+ typed observations backed by durable event/receipt references, keeps the
5
+ latest observation for a bounded set of present-tense categories, and emits
6
+ immutable snapshots. Model prose is never an observation and a situation
7
+ verdict never grants a capability or an action authority.
8
+
9
+ The module is deliberately deployment-neutral. ``JournalSituationAdapter``
10
+ understands Colony's durable host-journal envelope plus the structured event
11
+ families shared with the host. Deployments may construct ``SituationObservationV1``
12
+ directly for attested probes/transports without patching Colony.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ import hashlib
20
+ import json
21
+ import math
22
+ import os
23
+ import re
24
+ import sqlite3
25
+ import threading
26
+ import time
27
+ from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Sequence
28
+
29
+ from apsimo.events.journal import current_sequence, replay_events
30
+ from apsimo.scope_bounds import VIEWER_SCOPE_MAX_CHARS
31
+
32
+
33
+ SITUATION_VERSION = 1
34
+ OBSERVATION_VERSION = 1
35
+ VERDICT_VERSION = 1
36
+
37
+ SITUATION_CATEGORIES = (
38
+ "owner_engagement",
39
+ "activity",
40
+ "conversation",
41
+ "person",
42
+ "channel",
43
+ "service",
44
+ "approval",
45
+ "commitment",
46
+ "project",
47
+ "resource",
48
+ "capability",
49
+ "relationship",
50
+ )
51
+ _CATEGORY_SET = frozenset(SITUATION_CATEGORIES)
52
+ _SHAREABILITY = frozenset(
53
+ {"owner_private", "subject_private", "shared", "public"}
54
+ )
55
+ _SOURCE_KINDS = frozenset({
56
+ "colony_event",
57
+ "host_event",
58
+ "service_probe",
59
+ "transport_receipt",
60
+ "presence_observation",
61
+ "approval_receipt",
62
+ "project_receipt",
63
+ "work_receipt",
64
+ })
65
+ _DISALLOWED_SOURCE_KINDS = frozenset(
66
+ {"model", "model_assertion", "inference", "prose", "llm"}
67
+ )
68
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/+\-]{0,255}$")
69
+ _SAFE_STATE = re.compile(r"^[a-z][a-z0-9_.:\-]{0,63}$")
70
+ _SAFE_ATTRIBUTE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
71
+ _EVIDENCE_PREFIXES = frozenset({
72
+ "journal", "event", "receipt", "host-event", "colony-event",
73
+ "health", "presence", "approval", "project", "work-order",
74
+ "commitment", "channel", "service", "device", "capability",
75
+ "relationship", "situation-snapshot", "observation",
76
+ })
77
+ _JSON_SCALARS = (str, int, float, bool, type(None))
78
+
79
+ _DEFAULT_TTLS: Dict[str, float] = {
80
+ "owner_engagement": 120.0,
81
+ "activity": 300.0,
82
+ "conversation": 300.0,
83
+ "person": 300.0,
84
+ "channel": 180.0,
85
+ "service": 120.0,
86
+ "approval": 300.0,
87
+ "commitment": 3600.0,
88
+ "project": 300.0,
89
+ "resource": 300.0,
90
+ "capability": 300.0,
91
+ "relationship": 86400.0,
92
+ }
93
+
94
+
95
+ def situation_spine_mode() -> str:
96
+ """Return the explicit migration mode; unset/invalid always fails off."""
97
+
98
+ value = os.environ.get("COLONY_SITUATION_SPINE", "off").strip().lower()
99
+ return value if value in {"off", "shadow", "live"} else "off"
100
+
101
+
102
+ def situation_spine_enabled() -> bool:
103
+ return situation_spine_mode() in {"shadow", "live"}
104
+
105
+
106
+ def _canonical(value: Any) -> str:
107
+ return json.dumps(
108
+ value,
109
+ ensure_ascii=False,
110
+ separators=(",", ":"),
111
+ sort_keys=True,
112
+ default=str,
113
+ )
114
+
115
+
116
+ def _digest(value: Any) -> str:
117
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
118
+
119
+
120
+ def _epoch(value: Any, field: str) -> float:
121
+ if isinstance(value, bool):
122
+ raise ValueError(f"{field} must be a timestamp")
123
+ if isinstance(value, (int, float)):
124
+ result = float(value)
125
+ else:
126
+ raw = str(value or "").strip()
127
+ try:
128
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
129
+ except (TypeError, ValueError) as exc:
130
+ raise ValueError(f"{field} must be ISO-8601 or epoch seconds") from exc
131
+ if parsed.tzinfo is None:
132
+ parsed = parsed.replace(tzinfo=timezone.utc)
133
+ result = parsed.timestamp()
134
+ if not math.isfinite(result) or result <= 0.0 or result > 32_503_680_000.0:
135
+ raise ValueError(f"{field} is outside the supported range")
136
+ return result
137
+
138
+
139
+ def _iso(value: float) -> str:
140
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
141
+
142
+
143
+ def _safe_id(value: Any, field: str, *, maximum: int = 256) -> str:
144
+ result = str(value or "").strip()
145
+ if len(result) > maximum:
146
+ raise ValueError(f"{field} exceeds {maximum} characters")
147
+ if not _SAFE_ID.fullmatch(result):
148
+ raise ValueError(f"{field} is invalid")
149
+ return result
150
+
151
+
152
+ def _evidence_refs(values: Iterable[Any], *, maximum: int = 40) -> tuple[str, ...]:
153
+ refs: list[str] = []
154
+ for raw in values or ():
155
+ value = _safe_id(raw, "evidence reference")
156
+ if value.split(":", 1)[0] not in _EVIDENCE_PREFIXES:
157
+ raise ValueError("evidence reference has an unsupported origin")
158
+ if value not in refs:
159
+ refs.append(value)
160
+ if len(refs) > maximum:
161
+ raise ValueError(f"evidence references exceed {maximum}")
162
+ if not refs:
163
+ raise ValueError("at least one durable evidence reference is required")
164
+ return tuple(refs)
165
+
166
+
167
+ def _attributes(value: Optional[Mapping[str, Any]]) -> tuple[tuple[str, Any], ...]:
168
+ if value is None:
169
+ return ()
170
+ if not isinstance(value, Mapping) or len(value) > 20:
171
+ raise ValueError("attributes must be an object with at most 20 fields")
172
+ result: list[tuple[str, Any]] = []
173
+ for raw_key, raw_value in sorted(value.items()):
174
+ key = str(raw_key or "").strip().lower()
175
+ if not _SAFE_ATTRIBUTE.fullmatch(key):
176
+ raise ValueError("attribute key is invalid")
177
+ if not isinstance(raw_value, _JSON_SCALARS):
178
+ raise ValueError("situation attributes must be JSON scalars")
179
+ if isinstance(raw_value, float) and not math.isfinite(raw_value):
180
+ raise ValueError("situation attributes must be finite")
181
+ item = raw_value[:300] if isinstance(raw_value, str) else raw_value
182
+ result.append((key, item))
183
+ return tuple(result)
184
+
185
+
186
+ def _validate_scope(
187
+ subject_person_id: str,
188
+ viewer_scope: str,
189
+ shareability: str,
190
+ ) -> tuple[str, str, str]:
191
+ subject = _safe_id(subject_person_id, "subject_person_id", maximum=128)
192
+ viewer = _safe_id(
193
+ viewer_scope, "viewer_scope", maximum=VIEWER_SCOPE_MAX_CHARS,
194
+ )
195
+ sharing = str(shareability or "").strip().lower()
196
+ if sharing not in _SHAREABILITY:
197
+ raise ValueError("invalid shareability")
198
+ if sharing == "owner_private" and viewer != "owner":
199
+ raise ValueError("owner-private observations require owner viewer scope")
200
+ if sharing == "subject_private" and viewer != f"person:{subject}":
201
+ raise ValueError("subject-private observations require exact subject scope")
202
+ if sharing == "public" and viewer != "public":
203
+ raise ValueError("public observations require public viewer scope")
204
+ if sharing == "shared" and not (
205
+ viewer == "shared" or viewer.startswith("shared:")
206
+ ):
207
+ raise ValueError("shared observations require a shared viewer scope")
208
+ return subject, viewer, sharing
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class SituationObservationV1:
213
+ """One typed, time-bounded observation with durable provenance."""
214
+
215
+ observation_id: str
216
+ category: str
217
+ entity_id: str
218
+ state: str
219
+ active: bool
220
+ observed_at: float
221
+ fresh_until: float
222
+ evidence_refs: tuple[str, ...]
223
+ source_kind: str
224
+ subject_person_id: str
225
+ viewer_scope: str
226
+ shareability: str
227
+ attributes: tuple[tuple[str, Any], ...] = ()
228
+ schema: str = "SituationObservationV1"
229
+ version: int = OBSERVATION_VERSION
230
+
231
+ def __post_init__(self) -> None:
232
+ _safe_id(self.observation_id, "observation_id")
233
+ if self.category not in _CATEGORY_SET:
234
+ raise ValueError("unsupported situation category")
235
+ _safe_id(self.entity_id, "entity_id")
236
+ if not _SAFE_STATE.fullmatch(self.state):
237
+ raise ValueError("invalid situation state")
238
+ if type(self.active) is not bool:
239
+ raise ValueError("active must be a boolean")
240
+ observed = _epoch(self.observed_at, "observed_at")
241
+ fresh = _epoch(self.fresh_until, "fresh_until")
242
+ if fresh < observed or fresh - observed > 31_536_000.0:
243
+ raise ValueError("freshness interval is invalid or unbounded")
244
+ if _evidence_refs(self.evidence_refs) != self.evidence_refs:
245
+ raise ValueError("evidence references are not canonical")
246
+ source = str(self.source_kind or "").strip().lower()
247
+ if source in _DISALLOWED_SOURCE_KINDS or source not in _SOURCE_KINDS:
248
+ raise ValueError("source_kind is not a trusted observation source")
249
+ if _validate_scope(
250
+ self.subject_person_id, self.viewer_scope, self.shareability,
251
+ ) != (self.subject_person_id, self.viewer_scope, self.shareability):
252
+ raise ValueError("observation scope is not canonical")
253
+ if _attributes(dict(self.attributes)) != self.attributes:
254
+ raise ValueError("attributes are not canonical")
255
+ if self.schema != "SituationObservationV1" or self.version != 1:
256
+ raise ValueError("invalid situation observation schema")
257
+
258
+ @classmethod
259
+ def create(
260
+ cls,
261
+ *,
262
+ observation_id: str,
263
+ category: str,
264
+ entity_id: str,
265
+ state: str,
266
+ active: bool,
267
+ observed_at: Any,
268
+ fresh_until: Any = None,
269
+ ttl_seconds: Optional[float] = None,
270
+ evidence_refs: Iterable[Any],
271
+ source_kind: str,
272
+ subject_person_id: str,
273
+ viewer_scope: str,
274
+ shareability: str,
275
+ attributes: Optional[Mapping[str, Any]] = None,
276
+ ) -> "SituationObservationV1":
277
+ observed = _epoch(observed_at, "observed_at")
278
+ if fresh_until is None:
279
+ ttl = float(
280
+ ttl_seconds
281
+ if ttl_seconds is not None
282
+ else _DEFAULT_TTLS.get(category, 300.0)
283
+ )
284
+ if ttl < 1.0 or ttl > 31_536_000.0:
285
+ raise ValueError("ttl_seconds is outside the supported range")
286
+ fresh = observed + ttl
287
+ else:
288
+ fresh = _epoch(fresh_until, "fresh_until")
289
+ subject, viewer, sharing = _validate_scope(
290
+ subject_person_id, viewer_scope, shareability,
291
+ )
292
+ return cls(
293
+ observation_id=_safe_id(observation_id, "observation_id"),
294
+ category=str(category or "").strip().lower(),
295
+ entity_id=_safe_id(entity_id, "entity_id"),
296
+ state=str(state or "").strip().lower(),
297
+ active=active,
298
+ observed_at=observed,
299
+ fresh_until=fresh,
300
+ evidence_refs=_evidence_refs(evidence_refs),
301
+ source_kind=str(source_kind or "").strip().lower(),
302
+ subject_person_id=subject,
303
+ viewer_scope=viewer,
304
+ shareability=sharing,
305
+ attributes=_attributes(attributes),
306
+ )
307
+
308
+ @property
309
+ def digest(self) -> str:
310
+ return _digest(self.payload())
311
+
312
+ def payload(self) -> Dict[str, Any]:
313
+ return {
314
+ "schema": self.schema,
315
+ "version": self.version,
316
+ "observation_id": self.observation_id,
317
+ "category": self.category,
318
+ "entity_id": self.entity_id,
319
+ "state": self.state,
320
+ "active": self.active,
321
+ "observed_at": self.observed_at,
322
+ "fresh_until": self.fresh_until,
323
+ "evidence_refs": list(self.evidence_refs),
324
+ "source_kind": self.source_kind,
325
+ "subject_person_id": self.subject_person_id,
326
+ "viewer_scope": self.viewer_scope,
327
+ "shareability": self.shareability,
328
+ "attributes": dict(self.attributes),
329
+ }
330
+
331
+
332
+ def _queue_worker_count(value: Any, field: str) -> int:
333
+ if type(value) is not int or value < 0:
334
+ raise ValueError(f"{field} must be a non-negative integer")
335
+ return value
336
+
337
+
338
+ async def task_queue_resource_observation(
339
+ queue: Any,
340
+ *,
341
+ observed_at: Any = None,
342
+ ) -> SituationObservationV1:
343
+ """Project canonical queue readiness and worker liveness as one resource.
344
+
345
+ This is a direct probe of the queue's existing in-process readiness gate
346
+ and durable worker registry. It does not infer capacity from queued work
347
+ or configured workers: execution is available only when maintenance is
348
+ ready and at least one heartbeat-fresh worker is below capacity.
349
+ """
350
+
351
+ readiness_fn = getattr(queue, "execution_readiness", None)
352
+ stats_fn = getattr(queue, "get_queue_stats", None)
353
+ if not callable(readiness_fn) or not callable(stats_fn):
354
+ raise ValueError(
355
+ "task queue resource probe requires readiness and worker truth"
356
+ )
357
+ readiness = readiness_fn()
358
+ if not isinstance(readiness, Mapping):
359
+ raise ValueError("task queue execution readiness must be an object")
360
+ execution_ready = readiness.get("ready")
361
+ if type(execution_ready) is not bool:
362
+ raise ValueError("task queue execution readiness must be boolean")
363
+
364
+ stats = await stats_fn()
365
+ registered = _queue_worker_count(
366
+ getattr(stats, "registered_workers", None), "registered_workers",
367
+ )
368
+ active = _queue_worker_count(
369
+ getattr(stats, "active_workers", None), "active_workers",
370
+ )
371
+ stale = _queue_worker_count(
372
+ getattr(stats, "stale_workers", None), "stale_workers",
373
+ )
374
+ available = _queue_worker_count(
375
+ getattr(stats, "available_workers", None), "available_workers",
376
+ )
377
+ if registered != active + stale or available > active:
378
+ raise ValueError("task queue worker freshness counts are inconsistent")
379
+ heartbeat_ttl = getattr(stats, "worker_heartbeat_ttl_secs", None)
380
+ if (
381
+ isinstance(heartbeat_ttl, bool)
382
+ or not isinstance(heartbeat_ttl, (int, float))
383
+ or not math.isfinite(float(heartbeat_ttl))
384
+ or float(heartbeat_ttl) < 1.0
385
+ ):
386
+ raise ValueError("task queue worker heartbeat TTL is invalid")
387
+
388
+ observed = _epoch(
389
+ time.time() if observed_at is None else observed_at,
390
+ "observed_at",
391
+ )
392
+ capacity_available = execution_ready and available > 0
393
+ attributes = {
394
+ "active_workers": active,
395
+ "available_workers": available,
396
+ "capacity_available": capacity_available,
397
+ "execution_ready": execution_ready,
398
+ "registered_workers": registered,
399
+ "stale_workers": stale,
400
+ }
401
+ attestation = {
402
+ "schema": "TaskQueueExecutionReadinessProbeV1",
403
+ "version": 1,
404
+ "observed_at": observed,
405
+ "worker_heartbeat_ttl_secs": float(heartbeat_ttl),
406
+ **attributes,
407
+ }
408
+ attestation_digest = _digest(attestation)
409
+ owner = (
410
+ os.environ.get("COLONY_OWNER_PERSON_ID", "").strip()
411
+ or os.environ.get("COLONY_OWNER_CONTACT_ID", "").strip()
412
+ or "owner"
413
+ )
414
+ return SituationObservationV1.create(
415
+ observation_id=f"so-{attestation_digest[:24]}",
416
+ category="resource",
417
+ entity_id="task-queue-execution",
418
+ state="available" if capacity_available else "unavailable",
419
+ active=True,
420
+ observed_at=observed,
421
+ ttl_seconds=min(
422
+ _DEFAULT_TTLS["resource"], float(heartbeat_ttl),
423
+ ),
424
+ evidence_refs=(
425
+ f"health:task-queue-execution:{attestation_digest[:24]}",
426
+ ),
427
+ source_kind="service_probe",
428
+ subject_person_id=owner,
429
+ viewer_scope="owner",
430
+ shareability="owner_private",
431
+ attributes=attributes,
432
+ )
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class SituationFactV1:
437
+ observation_id: str
438
+ category: str
439
+ entity_id: str
440
+ state: str
441
+ active: bool
442
+ observed_at: float
443
+ fresh_until: float
444
+ freshness: str
445
+ evidence_refs: tuple[str, ...]
446
+ subject_person_id: str
447
+ viewer_scope: str
448
+ shareability: str
449
+ attributes: tuple[tuple[str, Any], ...] = ()
450
+
451
+ def public(self) -> Dict[str, Any]:
452
+ return {
453
+ "observation_id": self.observation_id,
454
+ "category": self.category,
455
+ "entity_id": self.entity_id,
456
+ "state": self.state,
457
+ "active": self.active,
458
+ "observed_at": self.observed_at,
459
+ "observed_at_iso": _iso(self.observed_at),
460
+ "fresh_until": self.fresh_until,
461
+ "fresh_until_iso": _iso(self.fresh_until),
462
+ "freshness": self.freshness,
463
+ "evidence_refs": list(self.evidence_refs),
464
+ "subject_person_id": self.subject_person_id,
465
+ "viewer_scope": self.viewer_scope,
466
+ "shareability": self.shareability,
467
+ "attributes": dict(self.attributes),
468
+ }
469
+
470
+
471
+ @dataclass(frozen=True)
472
+ class SituationSnapshotV1:
473
+ snapshot_id: str
474
+ snapshot_digest: str
475
+ subject_person_id: str
476
+ viewer_scope: str
477
+ shareability: str
478
+ as_of: float
479
+ facts: tuple[SituationFactV1, ...]
480
+ coverage: tuple[tuple[str, str], ...]
481
+ omitted: tuple[tuple[str, int], ...]
482
+ evidence_refs: tuple[str, ...]
483
+ schema: str = "SituationSnapshotV1"
484
+ version: int = SITUATION_VERSION
485
+
486
+ def freshness(self, category: str) -> str:
487
+ return dict(self.coverage).get(category, "unknown")
488
+
489
+ def active_facts(self, category: str) -> tuple[SituationFactV1, ...]:
490
+ return tuple(
491
+ fact for fact in self.facts
492
+ if fact.category == category and fact.active
493
+ )
494
+
495
+ def fresh_active_facts(self, category: str) -> tuple[SituationFactV1, ...]:
496
+ """Return only current active facts for an operational decision.
497
+
498
+ Stale facts remain visible through ``active_facts`` for explanation,
499
+ but they must not veto a decision merely because another entity made
500
+ the category-level coverage fresh.
501
+ """
502
+
503
+ return tuple(
504
+ fact for fact in self.facts
505
+ if fact.category == category
506
+ and fact.active
507
+ and fact.freshness == "fresh"
508
+ )
509
+
510
+ def public(self) -> Dict[str, Any]:
511
+ return {
512
+ "schema": self.schema,
513
+ "version": self.version,
514
+ "snapshot_id": self.snapshot_id,
515
+ "snapshot_digest": self.snapshot_digest,
516
+ "subject_person_id": self.subject_person_id,
517
+ "viewer_scope": self.viewer_scope,
518
+ "shareability": self.shareability,
519
+ "as_of": self.as_of,
520
+ "as_of_iso": _iso(self.as_of),
521
+ "coverage": dict(self.coverage),
522
+ "facts": [fact.public() for fact in self.facts],
523
+ "omitted": dict(self.omitted),
524
+ "evidence_refs": list(self.evidence_refs),
525
+ }
526
+
527
+
528
+ def _observation_from_payload(payload: Mapping[str, Any]) -> SituationObservationV1:
529
+ return SituationObservationV1.create(
530
+ observation_id=payload["observation_id"],
531
+ category=payload["category"],
532
+ entity_id=payload["entity_id"],
533
+ state=payload["state"],
534
+ active=payload["active"],
535
+ observed_at=payload["observed_at"],
536
+ fresh_until=payload["fresh_until"],
537
+ evidence_refs=payload["evidence_refs"],
538
+ source_kind=payload["source_kind"],
539
+ subject_person_id=payload["subject_person_id"],
540
+ viewer_scope=payload["viewer_scope"],
541
+ shareability=payload["shareability"],
542
+ attributes=payload.get("attributes") or {},
543
+ )
544
+
545
+
546
+ def _fact_from_payload(payload: Mapping[str, Any]) -> SituationFactV1:
547
+ return SituationFactV1(
548
+ observation_id=str(payload["observation_id"]),
549
+ category=str(payload["category"]),
550
+ entity_id=str(payload["entity_id"]),
551
+ state=str(payload["state"]),
552
+ active=bool(payload["active"]),
553
+ observed_at=float(payload["observed_at"]),
554
+ fresh_until=float(payload["fresh_until"]),
555
+ freshness=str(payload["freshness"]),
556
+ evidence_refs=tuple(payload.get("evidence_refs") or ()),
557
+ subject_person_id=str(payload["subject_person_id"]),
558
+ viewer_scope=str(payload["viewer_scope"]),
559
+ shareability=str(payload["shareability"]),
560
+ attributes=_attributes(payload.get("attributes") or {}),
561
+ )
562
+
563
+
564
+ class SituationStore:
565
+ """SQLite ledger for observations, latest facts, and immutable snapshots."""
566
+
567
+ def __init__(self, db_path: str) -> None:
568
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
569
+ self._conn.row_factory = sqlite3.Row
570
+ self._conn.execute("PRAGMA journal_mode=WAL")
571
+ self._lock = threading.RLock()
572
+ self._closed = False
573
+ self._conn.executescript(
574
+ """
575
+ CREATE TABLE IF NOT EXISTS situation_observations (
576
+ observation_id TEXT PRIMARY KEY,
577
+ observation_digest TEXT NOT NULL,
578
+ category TEXT NOT NULL,
579
+ entity_id TEXT NOT NULL,
580
+ subject_person_id TEXT NOT NULL,
581
+ viewer_scope TEXT NOT NULL,
582
+ payload TEXT NOT NULL,
583
+ recorded_at REAL NOT NULL
584
+ );
585
+ CREATE TABLE IF NOT EXISTS situation_facts (
586
+ subject_person_id TEXT NOT NULL,
587
+ viewer_scope TEXT NOT NULL,
588
+ category TEXT NOT NULL,
589
+ entity_id TEXT NOT NULL,
590
+ observation_id TEXT NOT NULL,
591
+ observed_at REAL NOT NULL,
592
+ fresh_until REAL NOT NULL,
593
+ payload TEXT NOT NULL,
594
+ PRIMARY KEY(subject_person_id,viewer_scope,category,entity_id)
595
+ );
596
+ CREATE INDEX IF NOT EXISTS idx_situation_fact_scope
597
+ ON situation_facts(subject_person_id,viewer_scope,category,observed_at);
598
+ CREATE TABLE IF NOT EXISTS situation_snapshots (
599
+ snapshot_id TEXT PRIMARY KEY,
600
+ snapshot_digest TEXT NOT NULL UNIQUE,
601
+ subject_person_id TEXT NOT NULL,
602
+ viewer_scope TEXT NOT NULL,
603
+ as_of REAL NOT NULL,
604
+ payload TEXT NOT NULL,
605
+ created_at REAL NOT NULL
606
+ );
607
+ CREATE TABLE IF NOT EXISTS situation_event_cursors (
608
+ consumer_id TEXT PRIMARY KEY,
609
+ last_seq INTEGER NOT NULL,
610
+ bootstrap_mode TEXT NOT NULL,
611
+ last_error TEXT NOT NULL DEFAULT '',
612
+ updated_at REAL NOT NULL
613
+ );
614
+ CREATE TABLE IF NOT EXISTS situation_event_receipts (
615
+ consumer_id TEXT NOT NULL,
616
+ event_id TEXT NOT NULL,
617
+ event_seq INTEGER NOT NULL,
618
+ event_digest TEXT NOT NULL,
619
+ disposition TEXT NOT NULL,
620
+ observation_ids TEXT NOT NULL,
621
+ processed_at REAL NOT NULL,
622
+ PRIMARY KEY(consumer_id,event_id)
623
+ );
624
+ CREATE TABLE IF NOT EXISTS situation_event_gaps (
625
+ gap_id INTEGER PRIMARY KEY AUTOINCREMENT,
626
+ consumer_id TEXT NOT NULL,
627
+ prior_cursor INTEGER NOT NULL,
628
+ resume_after INTEGER NOT NULL,
629
+ reason TEXT NOT NULL,
630
+ acknowledged_at REAL NOT NULL
631
+ );
632
+ """
633
+ )
634
+ self._conn.commit()
635
+
636
+ def close(self) -> None:
637
+ """Release the feature-owned SQLite handle; exact retries are safe."""
638
+
639
+ with self._lock:
640
+ if self._closed:
641
+ return
642
+ self._conn.close()
643
+ self._closed = True
644
+
645
+ def _apply_locked(self, observation: SituationObservationV1) -> str:
646
+ payload = _canonical(observation.payload())
647
+ existing = self._conn.execute(
648
+ "SELECT observation_digest FROM situation_observations "
649
+ "WHERE observation_id=?", (observation.observation_id,),
650
+ ).fetchone()
651
+ if existing is not None:
652
+ if existing["observation_digest"] != observation.digest:
653
+ raise ValueError("observation id replayed with conflicting content")
654
+ return "duplicate"
655
+
656
+ current = self._conn.execute(
657
+ "SELECT observation_id,observed_at,payload FROM situation_facts "
658
+ "WHERE subject_person_id=? AND viewer_scope=? AND category=? "
659
+ "AND entity_id=?",
660
+ (
661
+ observation.subject_person_id, observation.viewer_scope,
662
+ observation.category, observation.entity_id,
663
+ ),
664
+ ).fetchone()
665
+ if current is not None and float(current["observed_at"]) == observation.observed_at:
666
+ current_payload = json.loads(current["payload"])
667
+ if _digest(current_payload) != observation.digest:
668
+ raise ValueError("same-time observation conflicts with current fact")
669
+
670
+ self._conn.execute(
671
+ "INSERT INTO situation_observations "
672
+ "(observation_id,observation_digest,category,entity_id,"
673
+ "subject_person_id,viewer_scope,payload,recorded_at) "
674
+ "VALUES (?,?,?,?,?,?,?,?)",
675
+ (
676
+ observation.observation_id, observation.digest,
677
+ observation.category, observation.entity_id,
678
+ observation.subject_person_id, observation.viewer_scope,
679
+ payload, time.time(),
680
+ ),
681
+ )
682
+ if current is not None and float(current["observed_at"]) > observation.observed_at:
683
+ return "historical"
684
+ self._conn.execute(
685
+ "INSERT INTO situation_facts "
686
+ "(subject_person_id,viewer_scope,category,entity_id,observation_id,"
687
+ "observed_at,fresh_until,payload) VALUES (?,?,?,?,?,?,?,?) "
688
+ "ON CONFLICT(subject_person_id,viewer_scope,category,entity_id) "
689
+ "DO UPDATE SET observation_id=excluded.observation_id,"
690
+ "observed_at=excluded.observed_at,fresh_until=excluded.fresh_until,"
691
+ "payload=excluded.payload",
692
+ (
693
+ observation.subject_person_id, observation.viewer_scope,
694
+ observation.category, observation.entity_id,
695
+ observation.observation_id, observation.observed_at,
696
+ observation.fresh_until, payload,
697
+ ),
698
+ )
699
+ return "applied"
700
+
701
+ def ingest(self, observation: SituationObservationV1) -> Dict[str, Any]:
702
+ with self._lock:
703
+ try:
704
+ self._conn.execute("BEGIN IMMEDIATE")
705
+ disposition = self._apply_locked(observation)
706
+ self._conn.commit()
707
+ except Exception:
708
+ self._conn.rollback()
709
+ raise
710
+ return {
711
+ "disposition": disposition,
712
+ "observation_id": observation.observation_id,
713
+ }
714
+
715
+ @staticmethod
716
+ def _visible(row: sqlite3.Row, viewer_scope: str) -> bool:
717
+ payload = json.loads(row["payload"])
718
+ sharing = payload.get("shareability")
719
+ declared = payload.get("viewer_scope")
720
+ if sharing == "public":
721
+ return True
722
+ return declared == viewer_scope
723
+
724
+ def snapshot(
725
+ self,
726
+ *,
727
+ subject_person_id: str,
728
+ viewer_scope: str,
729
+ as_of: Optional[float] = None,
730
+ per_category_limit: int = 20,
731
+ total_limit: int = 160,
732
+ ) -> SituationSnapshotV1:
733
+ subject = _safe_id(subject_person_id, "subject_person_id", maximum=128)
734
+ viewer = _safe_id(
735
+ viewer_scope, "viewer_scope", maximum=VIEWER_SCOPE_MAX_CHARS,
736
+ )
737
+ point = _epoch(as_of if as_of is not None else time.time(), "as_of")
738
+ per_limit = max(1, min(50, int(per_category_limit)))
739
+ overall = max(1, min(200, int(total_limit)))
740
+ with self._lock:
741
+ rows = self._conn.execute(
742
+ "SELECT * FROM situation_facts WHERE subject_person_id=? "
743
+ "ORDER BY category ASC,observed_at DESC,entity_id ASC",
744
+ (subject,),
745
+ ).fetchall()
746
+
747
+ grouped: Dict[str, list[SituationFactV1]] = {
748
+ category: [] for category in SITUATION_CATEGORIES
749
+ }
750
+ omitted: Dict[str, int] = {}
751
+ for row in rows:
752
+ if not self._visible(row, viewer):
753
+ continue
754
+ payload = json.loads(row["payload"])
755
+ category = str(payload["category"])
756
+ if len(grouped[category]) >= per_limit:
757
+ omitted[category] = omitted.get(category, 0) + 1
758
+ continue
759
+ freshness = "fresh" if float(payload["fresh_until"]) >= point else "stale"
760
+ fact = SituationFactV1(
761
+ observation_id=str(payload["observation_id"]),
762
+ category=category,
763
+ entity_id=str(payload["entity_id"]),
764
+ state=str(payload["state"]),
765
+ active=bool(payload["active"]),
766
+ observed_at=float(payload["observed_at"]),
767
+ fresh_until=float(payload["fresh_until"]),
768
+ freshness=freshness,
769
+ evidence_refs=tuple(payload["evidence_refs"]),
770
+ subject_person_id=str(payload["subject_person_id"]),
771
+ viewer_scope=str(payload["viewer_scope"]),
772
+ shareability=str(payload["shareability"]),
773
+ attributes=_attributes(payload.get("attributes") or {}),
774
+ )
775
+ grouped[category].append(fact)
776
+
777
+ facts: list[SituationFactV1] = []
778
+ coverage: list[tuple[str, str]] = []
779
+ for category in SITUATION_CATEGORIES:
780
+ available = grouped[category]
781
+ if not available:
782
+ coverage.append((category, "unknown"))
783
+ continue
784
+ coverage.append((
785
+ category,
786
+ "fresh" if any(f.freshness == "fresh" for f in available)
787
+ else "stale",
788
+ ))
789
+ for fact in available:
790
+ if len(facts) >= overall:
791
+ omitted[category] = omitted.get(category, 0) + 1
792
+ else:
793
+ facts.append(fact)
794
+
795
+ refs = tuple(dict.fromkeys(
796
+ ref for fact in facts for ref in fact.evidence_refs
797
+ ))[:100]
798
+ sharing = (
799
+ "owner_private" if viewer == "owner"
800
+ else "subject_private" if viewer == f"person:{subject}"
801
+ else "public" if viewer == "public"
802
+ else "shared"
803
+ )
804
+ authority = {
805
+ "schema": "SituationSnapshotV1",
806
+ "version": 1,
807
+ "subject_person_id": subject,
808
+ "viewer_scope": viewer,
809
+ "shareability": sharing,
810
+ "as_of": point,
811
+ "facts": [fact.public() for fact in facts],
812
+ "coverage": dict(coverage),
813
+ "omitted": omitted,
814
+ "evidence_refs": list(refs),
815
+ }
816
+ digest = _digest(authority)
817
+ snapshot = SituationSnapshotV1(
818
+ snapshot_id=f"ss-{digest[:24]}",
819
+ snapshot_digest=digest,
820
+ subject_person_id=subject,
821
+ viewer_scope=viewer,
822
+ shareability=sharing,
823
+ as_of=point,
824
+ facts=tuple(facts),
825
+ coverage=tuple(coverage),
826
+ omitted=tuple(sorted(omitted.items())),
827
+ evidence_refs=refs,
828
+ )
829
+ with self._lock:
830
+ self._conn.execute(
831
+ "INSERT OR IGNORE INTO situation_snapshots "
832
+ "(snapshot_id,snapshot_digest,subject_person_id,viewer_scope,"
833
+ "as_of,payload,created_at) VALUES (?,?,?,?,?,?,?)",
834
+ (
835
+ snapshot.snapshot_id, snapshot.snapshot_digest, subject,
836
+ viewer, point, _canonical(snapshot.public()), time.time(),
837
+ ),
838
+ )
839
+ self._conn.commit()
840
+ return snapshot
841
+
842
+ def get_snapshot(
843
+ self, snapshot_id: str, *, viewer_scope: str,
844
+ ) -> Optional[SituationSnapshotV1]:
845
+ snapshot_key = _safe_id(snapshot_id, "snapshot_id")
846
+ viewer = _safe_id(
847
+ viewer_scope, "viewer_scope", maximum=VIEWER_SCOPE_MAX_CHARS,
848
+ )
849
+ with self._lock:
850
+ row = self._conn.execute(
851
+ "SELECT payload,viewer_scope FROM situation_snapshots "
852
+ "WHERE snapshot_id=?", (snapshot_key,),
853
+ ).fetchone()
854
+ if row is None:
855
+ return None
856
+ payload = json.loads(row["payload"])
857
+ if row["viewer_scope"] != viewer and payload.get("shareability") != "public":
858
+ return None
859
+ return SituationSnapshotV1(
860
+ snapshot_id=payload["snapshot_id"],
861
+ snapshot_digest=payload["snapshot_digest"],
862
+ subject_person_id=payload["subject_person_id"],
863
+ viewer_scope=payload["viewer_scope"],
864
+ shareability=payload["shareability"],
865
+ as_of=float(payload["as_of"]),
866
+ facts=tuple(_fact_from_payload(fact) for fact in payload["facts"]),
867
+ coverage=tuple(
868
+ (category, (payload.get("coverage") or {}).get(category, "unknown"))
869
+ for category in SITUATION_CATEGORIES
870
+ ),
871
+ omitted=tuple(sorted((payload.get("omitted") or {}).items())),
872
+ evidence_refs=tuple(payload.get("evidence_refs") or ()),
873
+ )
874
+
875
+ def initialize_cursor(
876
+ self, consumer_id: str, sequence: int, *, bootstrap_mode: str,
877
+ ) -> int:
878
+ consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
879
+ seq = max(0, int(sequence))
880
+ if bootstrap_mode not in {"tail", "replay"}:
881
+ raise ValueError("invalid bootstrap mode")
882
+ with self._lock:
883
+ self._conn.execute(
884
+ "INSERT OR IGNORE INTO situation_event_cursors "
885
+ "(consumer_id,last_seq,bootstrap_mode,updated_at) VALUES (?,?,?,?)",
886
+ (consumer, seq, bootstrap_mode, time.time()),
887
+ )
888
+ self._conn.commit()
889
+ row = self._conn.execute(
890
+ "SELECT last_seq FROM situation_event_cursors WHERE consumer_id=?",
891
+ (consumer,),
892
+ ).fetchone()
893
+ return int(row["last_seq"])
894
+
895
+ def cursor(self, consumer_id: str) -> Optional[int]:
896
+ consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
897
+ with self._lock:
898
+ row = self._conn.execute(
899
+ "SELECT last_seq FROM situation_event_cursors WHERE consumer_id=?",
900
+ (consumer,),
901
+ ).fetchone()
902
+ return int(row["last_seq"]) if row is not None else None
903
+
904
+ def set_cursor_error(self, consumer_id: str, error: str) -> None:
905
+ consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
906
+ with self._lock:
907
+ self._conn.execute(
908
+ "UPDATE situation_event_cursors SET last_error=?,updated_at=? "
909
+ "WHERE consumer_id=?", (str(error)[:200], time.time(), consumer),
910
+ )
911
+ self._conn.commit()
912
+
913
+ def acknowledge_gap(
914
+ self,
915
+ consumer_id: str,
916
+ *,
917
+ prior_cursor: int,
918
+ resume_after: int,
919
+ reason: str,
920
+ ) -> int:
921
+ consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
922
+ prior = int(prior_cursor)
923
+ resume = int(resume_after)
924
+ if resume <= prior:
925
+ raise ValueError("situation gap resume cursor must advance")
926
+ with self._lock:
927
+ self._conn.execute("BEGIN IMMEDIATE")
928
+ try:
929
+ row = self._conn.execute(
930
+ "SELECT last_seq FROM situation_event_cursors "
931
+ "WHERE consumer_id=?", (consumer,),
932
+ ).fetchone()
933
+ if row is None or int(row["last_seq"]) != prior:
934
+ raise ValueError("situation gap cursor changed")
935
+ now = time.time()
936
+ self._conn.execute(
937
+ "INSERT INTO situation_event_gaps "
938
+ "(consumer_id,prior_cursor,resume_after,reason,acknowledged_at) "
939
+ "VALUES (?,?,?,?,?)",
940
+ (consumer, prior, resume, str(reason)[:200], now),
941
+ )
942
+ self._conn.execute(
943
+ "UPDATE situation_event_cursors SET last_seq=?,last_error='',"
944
+ "updated_at=? WHERE consumer_id=?",
945
+ (resume, now, consumer),
946
+ )
947
+ self._conn.commit()
948
+ except Exception:
949
+ self._conn.rollback()
950
+ raise
951
+ return resume
952
+
953
+ def reduce_event(
954
+ self,
955
+ *,
956
+ consumer_id: str,
957
+ event_seq: int,
958
+ event_id: str,
959
+ event_digest: str,
960
+ observations: Sequence[SituationObservationV1],
961
+ disposition: str,
962
+ ) -> Dict[str, Any]:
963
+ consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
964
+ native_id = _safe_id(event_id, "event_id")
965
+ digest = _safe_id(event_digest, "event_digest")
966
+ seq = int(event_seq)
967
+ if seq < 1:
968
+ raise ValueError("event sequence must be positive")
969
+ with self._lock:
970
+ try:
971
+ self._conn.execute("BEGIN IMMEDIATE")
972
+ prior = self._conn.execute(
973
+ "SELECT * FROM situation_event_receipts WHERE consumer_id=? "
974
+ "AND event_id=?", (consumer, native_id),
975
+ ).fetchone()
976
+ cursor_row = self._conn.execute(
977
+ "SELECT last_seq FROM situation_event_cursors "
978
+ "WHERE consumer_id=?", (consumer,),
979
+ ).fetchone()
980
+ cursor = (
981
+ int(cursor_row["last_seq"])
982
+ if cursor_row is not None else None
983
+ )
984
+ if prior is not None:
985
+ if (
986
+ int(prior["event_seq"]) != seq
987
+ or prior["event_digest"] != digest
988
+ ):
989
+ raise ValueError(
990
+ "event id replayed with conflicting content",
991
+ )
992
+ self._conn.commit()
993
+ return {
994
+ "disposition": "duplicate_event",
995
+ "cursor": cursor,
996
+ "observation_ids": json.loads(prior["observation_ids"]),
997
+ }
998
+ if cursor is None:
999
+ raise ValueError("situation event cursor is not initialized")
1000
+ if seq != cursor + 1:
1001
+ raise ValueError("event sequence is not cursor-contiguous")
1002
+ applied = [self._apply_locked(item) for item in observations]
1003
+ ids = [item.observation_id for item in observations]
1004
+ self._conn.execute(
1005
+ "INSERT INTO situation_event_receipts "
1006
+ "(consumer_id,event_id,event_seq,event_digest,disposition,"
1007
+ "observation_ids,processed_at) VALUES (?,?,?,?,?,?,?)",
1008
+ (
1009
+ consumer, native_id, seq, digest, disposition,
1010
+ _canonical(ids), time.time(),
1011
+ ),
1012
+ )
1013
+ self._conn.execute(
1014
+ "UPDATE situation_event_cursors SET last_seq=?,last_error='',"
1015
+ "updated_at=? WHERE consumer_id=?",
1016
+ (seq, time.time(), consumer),
1017
+ )
1018
+ self._conn.commit()
1019
+ except Exception:
1020
+ self._conn.rollback()
1021
+ raise
1022
+ return {
1023
+ "disposition": disposition,
1024
+ "cursor": seq,
1025
+ "observation_ids": ids,
1026
+ "observation_dispositions": applied,
1027
+ }
1028
+
1029
+ def status(self, consumer_id: str) -> Dict[str, Any]:
1030
+ consumer = _safe_id(consumer_id, "consumer_id", maximum=128)
1031
+ with self._lock:
1032
+ row = self._conn.execute(
1033
+ "SELECT * FROM situation_event_cursors WHERE consumer_id=?",
1034
+ (consumer,),
1035
+ ).fetchone()
1036
+ counts = self._conn.execute(
1037
+ "SELECT disposition,COUNT(*) n FROM situation_event_receipts "
1038
+ "WHERE consumer_id=? GROUP BY disposition", (consumer,),
1039
+ ).fetchall()
1040
+ gaps = self._conn.execute(
1041
+ "SELECT COUNT(*) AS n,MAX(resume_after) AS last_resume "
1042
+ "FROM situation_event_gaps WHERE consumer_id=?", (consumer,),
1043
+ ).fetchone()
1044
+ return {
1045
+ "initialized": row is not None,
1046
+ "cursor": int(row["last_seq"]) if row is not None else None,
1047
+ "bootstrap_mode": row["bootstrap_mode"] if row is not None else None,
1048
+ "last_error": row["last_error"] if row is not None else "",
1049
+ "gaps": {
1050
+ "count": int(gaps["n"] or 0),
1051
+ "last_resume": int(gaps["last_resume"] or 0),
1052
+ },
1053
+ "dispositions": {item["disposition"]: int(item["n"]) for item in counts},
1054
+ }
1055
+
1056
+
1057
+ class JournalSituationAdapter:
1058
+ """Convert attested structured event families into observations.
1059
+
1060
+ Free-form fields such as ``summary`` and ``description`` are deliberately
1061
+ ignored. An event name/status may select a state; prose cannot.
1062
+ """
1063
+
1064
+ _STATE_KEYS = ("state", "status", "availability", "health")
1065
+ _ENTITY_KEYS: Dict[str, tuple[str, ...]] = {
1066
+ "owner_engagement": ("owner_person_id", "person_id"),
1067
+ "activity": ("activity_id", "session_id", "id"),
1068
+ "conversation": ("conversation_id", "session_id", "channel_id"),
1069
+ "person": ("person_id", "contact_id"),
1070
+ "channel": ("channel_id", "channel", "transport", "surface_id"),
1071
+ "service": ("service_id", "service", "component"),
1072
+ "approval": ("approval_id", "action_digest"),
1073
+ "commitment": ("commitment_id",),
1074
+ "project": ("project_id",),
1075
+ "resource": ("resource_id", "device_id", "work_order_id", "id"),
1076
+ "capability": ("capability_id", "capability"),
1077
+ "relationship": ("relationship_id", "contact_id", "person_id"),
1078
+ }
1079
+
1080
+ @staticmethod
1081
+ def _scope(data: Mapping[str, Any]) -> tuple[str, str, str]:
1082
+ subject = str(
1083
+ data.get("subject_person_id") or data.get("person_id")
1084
+ or data.get("contact_id")
1085
+ or os.environ.get("COLONY_OWNER_PERSON_ID", "").strip()
1086
+ or os.environ.get("COLONY_OWNER_CONTACT_ID", "").strip()
1087
+ or "owner"
1088
+ )
1089
+ sharing = str(
1090
+ data.get("shareability") or data.get("privacy_scope")
1091
+ or "owner_private"
1092
+ ).strip().lower()
1093
+ if sharing in {"shared", "public"} and data.get("boundary_attested") is not True:
1094
+ sharing = "owner_private"
1095
+ if sharing == "subject_private":
1096
+ viewer = f"person:{subject}"
1097
+ elif sharing == "public":
1098
+ viewer = "public"
1099
+ elif sharing == "shared":
1100
+ group = str(data.get("viewer_group") or "").strip()
1101
+ viewer = f"shared:{group}" if group else "shared"
1102
+ else:
1103
+ sharing, viewer = "owner_private", "owner"
1104
+ return _validate_scope(subject, viewer, sharing)
1105
+
1106
+ @staticmethod
1107
+ def _state(data: Mapping[str, Any], suffix: str) -> str:
1108
+ candidate = ""
1109
+ for key in JournalSituationAdapter._STATE_KEYS:
1110
+ value = str(data.get(key) or "").strip().lower()
1111
+ if value:
1112
+ candidate = value
1113
+ break
1114
+ candidate = candidate or suffix.replace("-", "_") or "observed"
1115
+ candidate = re.sub(r"[^a-z0-9_.:\-]", "_", candidate)[:64]
1116
+ return candidate if _SAFE_STATE.fullmatch(candidate) else "observed"
1117
+
1118
+ @staticmethod
1119
+ def _category(event_type: str) -> Optional[str]:
1120
+ if event_type == "conversation.turn":
1121
+ return "conversation"
1122
+ prefix = event_type.split(".", 1)[0]
1123
+ return {
1124
+ "presence": "person",
1125
+ "person": "person",
1126
+ "owner": "owner_engagement",
1127
+ "activity": "activity",
1128
+ "conversation": "conversation",
1129
+ "channel": "channel",
1130
+ "delivery": "channel",
1131
+ "outreach": "channel",
1132
+ "service": "service",
1133
+ "health": "service",
1134
+ "approval": "approval",
1135
+ "commitment": "commitment",
1136
+ "project": "project",
1137
+ "work_order": "resource",
1138
+ "work": "resource",
1139
+ "resource": "resource",
1140
+ "device": "resource",
1141
+ "capability": "capability",
1142
+ "relationship": "relationship",
1143
+ "contact": "relationship",
1144
+ }.get(prefix)
1145
+
1146
+ @staticmethod
1147
+ def _active(category: str, state: str) -> bool:
1148
+ terminal: Dict[str, frozenset[str]] = {
1149
+ "conversation": frozenset({"ended", "closed"}),
1150
+ "person": frozenset({"left", "absent"}),
1151
+ "approval": frozenset({
1152
+ "approved", "denied", "expired", "cancelled", "consumed",
1153
+ }),
1154
+ "commitment": frozenset({"fulfilled", "cancelled", "resolved"}),
1155
+ "project": frozenset({
1156
+ "completed", "verified", "failed", "abandoned",
1157
+ "cancelled", "resolved",
1158
+ }),
1159
+ "relationship": frozenset({"ended", "closed", "resolved"}),
1160
+ }
1161
+ # Service/channel/resource facts remain active even when negative: a
1162
+ # current "offline" probe is precisely what policy must see.
1163
+ return state not in terminal.get(category, frozenset())
1164
+
1165
+ @classmethod
1166
+ def adapt(
1167
+ cls,
1168
+ event: Mapping[str, Any],
1169
+ *,
1170
+ source_kind: str = "colony_event",
1171
+ ) -> tuple[tuple[SituationObservationV1, ...], str, str]:
1172
+ if not isinstance(event, Mapping):
1173
+ raise ValueError("event must be an object")
1174
+ seq = int(event.get("seq") or 0)
1175
+ event_id = _safe_id(
1176
+ event.get("ulid") or event.get("event_id") or f"journal-seq-{seq}",
1177
+ "event_id",
1178
+ )
1179
+ event_type = str(event.get("type") or "").strip().lower()
1180
+ if seq < 1 or not re.fullmatch(r"[a-z0-9][a-z0-9_.:\-]{0,127}", event_type):
1181
+ raise ValueError("malformed event identity")
1182
+ data = event.get("data")
1183
+ if not isinstance(data, Mapping):
1184
+ raise ValueError("event data must be an object")
1185
+ occurred = _epoch(
1186
+ event.get("occurredAt") or event.get("occurred_at")
1187
+ or event.get("recordedAt"),
1188
+ "occurred_at",
1189
+ )
1190
+ raw_digest = _digest({
1191
+ "type": event_type, "occurred_at": occurred, "data": data,
1192
+ })
1193
+ if event_type == "cognition.external.service_state":
1194
+ from apsimo.cognition.external_events import (
1195
+ validate_external_journal_projection,
1196
+ )
1197
+
1198
+ projection = validate_external_journal_projection(event_type, data)
1199
+ external = projection["attributes"]
1200
+ entity = str(external["service"])
1201
+ state = str(external["state"])
1202
+ subject, viewer, sharing = _validate_scope(
1203
+ str(projection["subject_person_id"]),
1204
+ str(projection["viewer_scope"]),
1205
+ str(projection["shareability"]),
1206
+ )
1207
+ evidence = (f"journal:{seq}:{event_id}",)
1208
+ attributes: Dict[str, Any] = {"event_type": event_type}
1209
+ for key in ("latency_ms", "observed_samples"):
1210
+ if key in external:
1211
+ attributes[key] = external[key]
1212
+ observation = SituationObservationV1.create(
1213
+ observation_id=(
1214
+ f"so-{_digest((event_id, 'service', entity))[:24]}"
1215
+ ),
1216
+ category="service",
1217
+ entity_id=entity,
1218
+ state=state,
1219
+ active=cls._active("service", state),
1220
+ observed_at=projection["external_occurred_at"],
1221
+ ttl_seconds=_DEFAULT_TTLS["service"],
1222
+ evidence_refs=evidence,
1223
+ source_kind=source_kind,
1224
+ subject_person_id=subject,
1225
+ viewer_scope=viewer,
1226
+ shareability=sharing,
1227
+ attributes=attributes,
1228
+ )
1229
+ return (observation,), "projected", raw_digest
1230
+
1231
+ category = cls._category(event_type)
1232
+ if category is None:
1233
+ return (), "unmapped_event_type", raw_digest
1234
+
1235
+ suffix = event_type.rsplit(".", 1)[-1]
1236
+ state = cls._state(data, suffix)
1237
+ subject, viewer, sharing = cls._scope(data)
1238
+ entity = ""
1239
+ for key in cls._ENTITY_KEYS[category]:
1240
+ raw = str(data.get(key) or "").strip()
1241
+ if raw and _SAFE_ID.fullmatch(raw):
1242
+ entity = raw
1243
+ break
1244
+ if not entity and category == "owner_engagement":
1245
+ entity = subject
1246
+ if not entity:
1247
+ return (), f"missing_{category}_entity", raw_digest
1248
+ evidence = (f"journal:{seq}:{event_id}",)
1249
+ active = cls._active(category, state)
1250
+ attributes: Dict[str, Any] = {"event_type": event_type}
1251
+ for key in (
1252
+ "channel_id", "channel", "surface_id", "transport", "interruption_cost",
1253
+ "pending_count", "capacity_available", "recipient_person_id",
1254
+ ):
1255
+ value = data.get(key)
1256
+ if isinstance(value, _JSON_SCALARS) and value is not None:
1257
+ attributes[key] = value
1258
+ primary = SituationObservationV1.create(
1259
+ observation_id=f"so-{_digest((event_id, category, entity))[:24]}",
1260
+ category=category,
1261
+ entity_id=entity,
1262
+ state=state,
1263
+ active=active,
1264
+ observed_at=occurred,
1265
+ ttl_seconds=_DEFAULT_TTLS[category],
1266
+ evidence_refs=evidence,
1267
+ source_kind=source_kind,
1268
+ subject_person_id=subject,
1269
+ viewer_scope=viewer,
1270
+ shareability=sharing,
1271
+ attributes=attributes,
1272
+ )
1273
+ observations: list[SituationObservationV1] = [primary]
1274
+
1275
+ # A conversation turn is also real, short-lived evidence of the
1276
+ # participant and channel. It says nothing about room occupancy.
1277
+ if event_type == "conversation.turn":
1278
+ contact = str(data.get("contact_id") or "").strip()
1279
+ channel = str(data.get("channel_id") or "").strip()
1280
+ if contact and _SAFE_ID.fullmatch(contact):
1281
+ observations.append(SituationObservationV1.create(
1282
+ observation_id=f"so-{_digest((event_id, 'person', contact))[:24]}",
1283
+ category="person", entity_id=contact, state="recently_active",
1284
+ active=True, observed_at=occurred,
1285
+ ttl_seconds=_DEFAULT_TTLS["person"], evidence_refs=evidence,
1286
+ source_kind=source_kind, subject_person_id=subject,
1287
+ viewer_scope=viewer, shareability=sharing,
1288
+ attributes={"channel_id": channel} if channel else {},
1289
+ ))
1290
+ if channel and _SAFE_ID.fullmatch(channel):
1291
+ observations.append(SituationObservationV1.create(
1292
+ observation_id=f"so-{_digest((event_id, 'channel', channel))[:24]}",
1293
+ category="channel", entity_id=channel, state="recently_active",
1294
+ active=True, observed_at=occurred,
1295
+ ttl_seconds=_DEFAULT_TTLS["channel"], evidence_refs=evidence,
1296
+ source_kind=source_kind, subject_person_id=subject,
1297
+ viewer_scope=viewer, shareability=sharing,
1298
+ ))
1299
+ return tuple(observations), "projected", raw_digest
1300
+
1301
+
1302
+ class SituationReducer:
1303
+ """Idempotently consume the durable host journal into ``SituationStore``."""
1304
+
1305
+ def __init__(
1306
+ self,
1307
+ store: SituationStore,
1308
+ *,
1309
+ consumer_id: str = "situation-spine-v1",
1310
+ replay_fn: Callable[..., Mapping[str, Any]] = replay_events,
1311
+ current_sequence_fn: Callable[[], int] = current_sequence,
1312
+ adapter: Any = JournalSituationAdapter,
1313
+ ) -> None:
1314
+ self.store = store
1315
+ self.consumer_id = consumer_id
1316
+ self._replay = replay_fn
1317
+ self._current_sequence = current_sequence_fn
1318
+ self._adapter = adapter
1319
+
1320
+ @property
1321
+ def mode(self) -> str:
1322
+ return situation_spine_mode()
1323
+
1324
+ def _bootstrap_mode(self) -> str:
1325
+ value = os.environ.get(
1326
+ "COLONY_SITUATION_BOOTSTRAP", "tail",
1327
+ ).strip().lower()
1328
+ return value if value in {"tail", "replay"} else "tail"
1329
+
1330
+ @staticmethod
1331
+ def _replay_integrity_error(snapshot: Mapping[str, Any]) -> str:
1332
+ if snapshot.get("replayError"):
1333
+ return "journal_unavailable"
1334
+ try:
1335
+ corrupt = int(snapshot.get("corruptCount") or 0)
1336
+ first = int(snapshot.get("firstAvailableSeq") or 0)
1337
+ high = int(snapshot.get("journalLastSeq") or 0)
1338
+ except (TypeError, ValueError):
1339
+ return "journal_metadata_invalid"
1340
+ if corrupt < 0 or first < 0 or high < 0 or (first and first > high):
1341
+ return "journal_metadata_invalid"
1342
+ if corrupt:
1343
+ return f"journal_corruption_detected:{corrupt}"
1344
+ if not isinstance(snapshot.get("events") or (), (list, tuple)):
1345
+ return "journal_metadata_invalid"
1346
+ return ""
1347
+
1348
+ @staticmethod
1349
+ def _gap_policy() -> str:
1350
+ return os.environ.get(
1351
+ "COLONY_SITUATION_GAP_POLICY", "stop",
1352
+ ).strip().lower()
1353
+
1354
+ def _stop(self, error: str, *, processed: int = 0,
1355
+ cursor: Optional[int] = None) -> Dict[str, Any]:
1356
+ self.store.set_cursor_error(self.consumer_id, error)
1357
+ result: Dict[str, Any] = {
1358
+ "enabled": True, "mode": self.mode,
1359
+ "processed": int(processed), "error": error,
1360
+ }
1361
+ if cursor is not None:
1362
+ result["cursor"] = int(cursor)
1363
+ return result
1364
+
1365
+ def run_once(self, *, limit: int = 100) -> Dict[str, Any]:
1366
+ if self.mode == "off":
1367
+ return {"enabled": False, "processed": 0, "mode": "off"}
1368
+ cursor = self.store.cursor(self.consumer_id)
1369
+ if cursor is None:
1370
+ head = self._replay(after_seq=0, limit=1)
1371
+ bootstrap = self._bootstrap_mode()
1372
+ integrity_error = self._replay_integrity_error(head)
1373
+ if integrity_error:
1374
+ initial = 0
1375
+ else:
1376
+ first = int(head.get("firstAvailableSeq") or 0)
1377
+ high = int(head.get("journalLastSeq") or 0)
1378
+ initial = high if bootstrap == "tail" else max(0, first - 1)
1379
+ cursor = self.store.initialize_cursor(
1380
+ self.consumer_id, initial, bootstrap_mode=bootstrap,
1381
+ )
1382
+ if integrity_error:
1383
+ return self._stop(integrity_error, cursor=cursor)
1384
+ if bootstrap == "tail":
1385
+ return {
1386
+ "enabled": True, "mode": self.mode, "processed": 0,
1387
+ "bootstrapped": True, "bootstrap_mode": bootstrap,
1388
+ "cursor": cursor,
1389
+ }
1390
+
1391
+ batch = self._replay(
1392
+ after_seq=cursor, limit=max(1, min(500, int(limit))),
1393
+ )
1394
+ integrity_error = self._replay_integrity_error(batch)
1395
+ if integrity_error:
1396
+ return self._stop(integrity_error, cursor=cursor)
1397
+ journal_high = int(batch.get("journalLastSeq") or 0)
1398
+ if cursor > journal_high:
1399
+ return self._stop(
1400
+ f"event_journal_rewind:{cursor}:{journal_high}",
1401
+ cursor=cursor,
1402
+ )
1403
+ first = int(batch.get("firstAvailableSeq") or 0)
1404
+ if first and cursor < first - 1:
1405
+ error = f"journal_retention_gap:{cursor}:{first}"
1406
+ if self._gap_policy() != "acknowledge":
1407
+ return self._stop(error, cursor=cursor)
1408
+ cursor = self.store.acknowledge_gap(
1409
+ self.consumer_id,
1410
+ prior_cursor=cursor,
1411
+ resume_after=first - 1,
1412
+ reason=error,
1413
+ )
1414
+ batch = self._replay(
1415
+ after_seq=cursor, limit=max(1, min(500, int(limit))),
1416
+ )
1417
+ integrity_error = self._replay_integrity_error(batch)
1418
+ if integrity_error:
1419
+ return self._stop(integrity_error, cursor=cursor)
1420
+ journal_high = int(batch.get("journalLastSeq") or 0)
1421
+ if cursor > journal_high:
1422
+ return self._stop(
1423
+ f"event_journal_rewind:{cursor}:{journal_high}",
1424
+ cursor=cursor,
1425
+ )
1426
+
1427
+ counts: Dict[str, int] = {}
1428
+ last_seq = cursor
1429
+ for event in batch.get("events") or ():
1430
+ try:
1431
+ if not isinstance(event, Mapping):
1432
+ raise ValueError("journal event is not an object")
1433
+ sequence = int(event.get("seq") or 0)
1434
+ if sequence <= last_seq:
1435
+ raise ValueError("journal event sequence is not increasing")
1436
+ if sequence != last_seq + 1:
1437
+ error = f"journal_sequence_gap:{last_seq}:{sequence}"
1438
+ if self._gap_policy() != "acknowledge":
1439
+ return self._stop(
1440
+ error, processed=sum(counts.values()),
1441
+ cursor=last_seq,
1442
+ )
1443
+ last_seq = self.store.acknowledge_gap(
1444
+ self.consumer_id,
1445
+ prior_cursor=last_seq,
1446
+ resume_after=sequence - 1,
1447
+ reason=error,
1448
+ )
1449
+ observations, disposition, digest = self._adapter.adapt(event)
1450
+ result = self.store.reduce_event(
1451
+ consumer_id=self.consumer_id,
1452
+ event_seq=int(event["seq"]),
1453
+ event_id=str(event.get("ulid") or f"journal-seq-{event['seq']}"),
1454
+ event_digest=digest,
1455
+ observations=observations,
1456
+ disposition=disposition,
1457
+ )
1458
+ key = str(result["disposition"])
1459
+ except Exception as exc:
1460
+ self.store.set_cursor_error(
1461
+ self.consumer_id, f"malformed_event:{type(exc).__name__}",
1462
+ )
1463
+ return {
1464
+ "enabled": True,
1465
+ "mode": self.mode,
1466
+ "processed": sum(counts.values()),
1467
+ "error": f"malformed_event:{type(exc).__name__}",
1468
+ }
1469
+ counts[key] = counts.get(key, 0) + 1
1470
+ last_seq = int(result.get("cursor") or last_seq)
1471
+ journal_high = int(batch.get("journalLastSeq") or 0)
1472
+ if not bool(batch.get("hasMore")) and journal_high > last_seq:
1473
+ error = f"journal_sequence_gap:{last_seq}:{journal_high + 1}"
1474
+ if self._gap_policy() != "acknowledge":
1475
+ return self._stop(
1476
+ error, processed=sum(counts.values()), cursor=last_seq,
1477
+ )
1478
+ last_seq = self.store.acknowledge_gap(
1479
+ self.consumer_id,
1480
+ prior_cursor=last_seq,
1481
+ resume_after=journal_high,
1482
+ reason=error,
1483
+ )
1484
+ return {
1485
+ "enabled": True,
1486
+ "mode": self.mode,
1487
+ "processed": sum(counts.values()),
1488
+ "dispositions": counts,
1489
+ "cursor": last_seq,
1490
+ "has_more": bool(batch.get("hasMore")),
1491
+ }
1492
+
1493
+ def status(self) -> Dict[str, Any]:
1494
+ result = self.store.status(self.consumer_id)
1495
+ result.update({
1496
+ "mode": self.mode,
1497
+ "enabled": self.mode != "off",
1498
+ "healthy": not bool(result.get("last_error")),
1499
+ "journal_high_water": int(self._current_sequence()),
1500
+ })
1501
+ return result
1502
+
1503
+
1504
+ @dataclass(frozen=True)
1505
+ class SituationPolicyVerdictV1:
1506
+ verdict_id: str
1507
+ action: str
1508
+ allowed: bool
1509
+ reason: str
1510
+ evidence_refs: tuple[str, ...]
1511
+ snapshot_id: str
1512
+ operation: str
1513
+ does_not_grant_authority: bool = True
1514
+ schema: str = "SituationPolicyVerdictV1"
1515
+ version: int = VERDICT_VERSION
1516
+
1517
+ def __post_init__(self) -> None:
1518
+ _safe_id(self.verdict_id, "verdict_id")
1519
+ _safe_id(self.snapshot_id, "snapshot_id")
1520
+ if self.action not in {"allow", "ask", "hold"}:
1521
+ raise ValueError("invalid situation policy action")
1522
+ if self.allowed is not (self.action == "allow"):
1523
+ raise ValueError("situation policy allowed/action mismatch")
1524
+ if self.does_not_grant_authority is not True:
1525
+ raise ValueError("situation verdict cannot grant authority")
1526
+ if _evidence_refs(self.evidence_refs) != self.evidence_refs:
1527
+ raise ValueError("situation verdict evidence is not canonical")
1528
+ if self.schema != "SituationPolicyVerdictV1" or self.version != 1:
1529
+ raise ValueError("invalid situation policy schema")
1530
+
1531
+ def as_policy_result(self) -> Dict[str, Any]:
1532
+ """Shape consumed by P3's deterministic policy normalizer."""
1533
+
1534
+ return {
1535
+ "allowed": self.allowed,
1536
+ "reason": self.reason,
1537
+ "evidence_refs": list(self.evidence_refs),
1538
+ "situation_verdict_ref": f"situation-verdict:{self.verdict_id}",
1539
+ "does_not_grant_authority": True,
1540
+ }
1541
+
1542
+
1543
+ class AppropriatenessGate:
1544
+ """Pure situational policy. It can hold/ask; it cannot authorize work."""
1545
+
1546
+ _DEGRADED = frozenset({
1547
+ "degraded", "down", "offline", "unavailable", "unhealthy", "failed",
1548
+ })
1549
+ _BUSY = frozenset({
1550
+ "busy", "do_not_disturb", "sleeping", "driving", "in_call",
1551
+ })
1552
+
1553
+ def __init__(self, *, mode_fn: Callable[[], str] = situation_spine_mode) -> None:
1554
+ self._mode_fn = mode_fn
1555
+
1556
+ @staticmethod
1557
+ def _verdict(
1558
+ snapshot: SituationSnapshotV1,
1559
+ operation: str,
1560
+ action: str,
1561
+ reason: str,
1562
+ refs: Iterable[str],
1563
+ ) -> SituationPolicyVerdictV1:
1564
+ evidence = tuple(dict.fromkeys((
1565
+ f"situation-snapshot:{snapshot.snapshot_id}", *refs,
1566
+ )))[:40]
1567
+ authority = {
1568
+ "snapshot_id": snapshot.snapshot_id,
1569
+ "operation": operation,
1570
+ "action": action,
1571
+ "reason": reason,
1572
+ "evidence_refs": evidence,
1573
+ "does_not_grant_authority": True,
1574
+ }
1575
+ return SituationPolicyVerdictV1(
1576
+ verdict_id=_digest(authority)[:24],
1577
+ action=action,
1578
+ allowed=action == "allow",
1579
+ reason=reason,
1580
+ evidence_refs=evidence,
1581
+ snapshot_id=snapshot.snapshot_id,
1582
+ operation=operation,
1583
+ )
1584
+
1585
+ def evaluate(
1586
+ self,
1587
+ snapshot: SituationSnapshotV1,
1588
+ *,
1589
+ operation: str,
1590
+ required_categories: Iterable[str] = (),
1591
+ target_channel: str = "",
1592
+ recipient_person_id: str = "",
1593
+ ) -> SituationPolicyVerdictV1:
1594
+ op = str(operation or "").strip().lower()
1595
+ if not _SAFE_STATE.fullmatch(op):
1596
+ raise ValueError("operation is invalid")
1597
+ mode = self._mode_fn()
1598
+ if mode != "live":
1599
+ reason = "situation_spine_off" if mode == "off" else "situation_shadow_observation_only"
1600
+ return self._verdict(snapshot, op, "hold", reason, ())
1601
+
1602
+ required = tuple(dict.fromkeys(str(item).strip() for item in required_categories))
1603
+ if any(item not in _CATEGORY_SET for item in required):
1604
+ raise ValueError("required category is unsupported")
1605
+ for category in required:
1606
+ freshness = snapshot.freshness(category)
1607
+ if freshness == "unknown":
1608
+ return self._verdict(
1609
+ snapshot, op, "ask", f"situation_unknown:{category}", (),
1610
+ )
1611
+ if freshness == "stale":
1612
+ stale = snapshot.active_facts(category)
1613
+ return self._verdict(
1614
+ snapshot, op, "hold", f"situation_stale:{category}",
1615
+ (ref for fact in stale for ref in fact.evidence_refs),
1616
+ )
1617
+
1618
+ if "service" in required:
1619
+ service_facts = snapshot.fresh_active_facts("service")
1620
+ degraded = [
1621
+ fact for fact in service_facts if fact.state in self._DEGRADED
1622
+ ]
1623
+ if degraded:
1624
+ return self._verdict(
1625
+ snapshot, op, "hold", "required_service_degraded",
1626
+ (ref for fact in degraded for ref in fact.evidence_refs),
1627
+ )
1628
+
1629
+ for category in ("resource", "capability"):
1630
+ if category not in required:
1631
+ continue
1632
+ unavailable = [
1633
+ fact for fact in snapshot.fresh_active_facts(category)
1634
+ if fact.state in self._DEGRADED
1635
+ or dict(fact.attributes).get("capacity_available") is False
1636
+ ]
1637
+ if unavailable:
1638
+ return self._verdict(
1639
+ snapshot, op, "hold", f"required_{category}_unavailable",
1640
+ (ref for fact in unavailable for ref in fact.evidence_refs),
1641
+ )
1642
+
1643
+ if "approval" in required:
1644
+ pending_approvals = [
1645
+ fact for fact in snapshot.fresh_active_facts("approval")
1646
+ if fact.state in {
1647
+ "pending", "required", "awaiting_approval", "requested",
1648
+ }
1649
+ ]
1650
+ if pending_approvals:
1651
+ return self._verdict(
1652
+ snapshot, op, "ask", "pending_approval_requires_decision",
1653
+ (
1654
+ ref for fact in pending_approvals
1655
+ for ref in fact.evidence_refs
1656
+ ),
1657
+ )
1658
+
1659
+ if target_channel:
1660
+ channel = [
1661
+ fact for fact in snapshot.active_facts("channel")
1662
+ if fact.entity_id == target_channel
1663
+ ]
1664
+ if not channel:
1665
+ return self._verdict(
1666
+ snapshot, op, "ask", "target_channel_state_unknown", (),
1667
+ )
1668
+ if channel[0].freshness != "fresh" or channel[0].state in self._DEGRADED:
1669
+ return self._verdict(
1670
+ snapshot, op, "hold", "target_channel_unavailable",
1671
+ channel[0].evidence_refs,
1672
+ )
1673
+
1674
+ if op in {"outreach", "proactive_delivery", "interrupt"}:
1675
+ engagement = snapshot.fresh_active_facts("owner_engagement")
1676
+ if not engagement or snapshot.freshness("owner_engagement") != "fresh":
1677
+ return self._verdict(
1678
+ snapshot, op, "ask", "owner_availability_unknown", (),
1679
+ )
1680
+ busy = [fact for fact in engagement if fact.state in self._BUSY]
1681
+ if busy:
1682
+ return self._verdict(
1683
+ snapshot, op, "hold", "owner_interruption_cost_high",
1684
+ (ref for fact in busy for ref in fact.evidence_refs),
1685
+ )
1686
+ active_conversations = snapshot.fresh_active_facts("conversation")
1687
+ if active_conversations:
1688
+ return self._verdict(
1689
+ snapshot, op, "hold", "active_conversation_in_progress",
1690
+ (ref for fact in active_conversations for ref in fact.evidence_refs),
1691
+ )
1692
+ if recipient_person_id:
1693
+ others = [
1694
+ fact for fact in snapshot.fresh_active_facts("person")
1695
+ if fact.entity_id != recipient_person_id
1696
+ ]
1697
+ if others:
1698
+ return self._verdict(
1699
+ snapshot, op, "hold", "recipient_privacy_context_uncertain",
1700
+ (ref for fact in others for ref in fact.evidence_refs),
1701
+ )
1702
+
1703
+ return self._verdict(
1704
+ snapshot, op, "allow", "fresh_situation_allows", snapshot.evidence_refs,
1705
+ )
1706
+
1707
+ def for_goal_proposal(
1708
+ self,
1709
+ proposal: Any,
1710
+ concern: Any,
1711
+ snapshot: SituationSnapshotV1,
1712
+ *,
1713
+ required_categories: Iterable[str] = ("resource", "service"),
1714
+ ) -> Dict[str, Any]:
1715
+ """P3 adapter; scope equality is checked before situational policy."""
1716
+
1717
+ if (
1718
+ str(getattr(proposal, "subject_person_id", ""))
1719
+ != snapshot.subject_person_id
1720
+ or str(getattr(proposal, "viewer_scope", "")) != snapshot.viewer_scope
1721
+ or str(getattr(concern, "subject_person_id", ""))
1722
+ != snapshot.subject_person_id
1723
+ ):
1724
+ return self._verdict(
1725
+ snapshot, "project_start", "hold", "situation_scope_mismatch", (),
1726
+ ).as_policy_result()
1727
+ return self.evaluate(
1728
+ snapshot,
1729
+ operation="project_start",
1730
+ required_categories=required_categories,
1731
+ ).as_policy_result()