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,2388 @@
1
+ """Cognitive workspace: continuity of thought between interactions (Mind M2).
2
+
3
+ A bounded store of active concerns, each carrying a salience score that
4
+ events raise and time decays. When idle capacity exists the scheduler pops
5
+ the most salient concern and runs one bounded thinking job; the outcome
6
+ updates memory, resolves the concern, proposes an initiative or experiment,
7
+ or concludes "nothing to do" (which decays salience faster so rumination
8
+ cannot persist). A nightly sleep window lets the heavy standing agenda run
9
+ when the cluster is idle.
10
+
11
+ This is the difference between "runs phases every N hours" and "has
12
+ something on her mind." Generic in ColonyAI; the deployment feeds it events
13
+ and supplies the thinker (the LLM reasoning path).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import sqlite3
24
+ import threading
25
+ import time
26
+ import uuid
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime, timezone
29
+ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
30
+
31
+ from apsimo.scope_bounds import (
32
+ SUBJECT_PERSON_ID_MAX_CHARS,
33
+ VIEWER_SCOPE_MAX_CHARS,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ CONCERN_KINDS = ("question", "goal", "thread", "anomaly", "maintenance")
39
+ RECENT_RESOLUTIONS_LIMIT = 20
40
+ _UTC_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
41
+ _OWNER_RESOLUTION_PROVENANCE = "owner_api"
42
+ _LEGACY_RESOLUTION_PROVENANCE = "legacy_unrecorded"
43
+
44
+
45
+ class ConcernResolutionConflict(ValueError):
46
+ """An immutable terminal concern receipt conflicts with a new claim."""
47
+
48
+
49
+ def _resolution_material(
50
+ *,
51
+ concern_id: str,
52
+ outcome: Optional[str],
53
+ note: str,
54
+ cascade: Optional[bool],
55
+ resolved_by: Optional[str],
56
+ resolved_at: str,
57
+ provenance: str,
58
+ ) -> Dict[str, Any]:
59
+ if cascade is not None and type(cascade) is not bool:
60
+ raise ValueError("concern resolution cascade must be boolean or null")
61
+ note_digest = hashlib.sha256(note.encode("utf-8")).hexdigest()
62
+ payload = {
63
+ "schema": "ColonyConcernResolutionReceiptV1",
64
+ "version": 1,
65
+ "concern_id": concern_id,
66
+ "outcome": outcome,
67
+ "note": note,
68
+ "note_digest": note_digest,
69
+ "cascade": cascade,
70
+ "resolved_by": resolved_by,
71
+ "resolved_at": resolved_at,
72
+ "provenance": provenance,
73
+ }
74
+ encoded = json.dumps(
75
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
76
+ ).encode("utf-8")
77
+ record_digest = hashlib.sha256(encoded).hexdigest()
78
+ return {
79
+ **payload,
80
+ "resolution_id": f"concern-resolution:{record_digest}",
81
+ "record_digest": record_digest,
82
+ }
83
+
84
+
85
+ def _canonical_json(value: Any) -> str:
86
+ return json.dumps(
87
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
88
+ )
89
+
90
+
91
+ def _cascade_source_refs(values: Any) -> List[str]:
92
+ if not isinstance(values, (list, tuple)):
93
+ raise ValueError("concern cascade sources must be a list")
94
+ refs: List[str] = []
95
+ for value in values:
96
+ if type(value) is not str or not value or value != value.strip():
97
+ raise ValueError("concern cascade source reference is invalid")
98
+ if len(value) > 512:
99
+ raise ValueError("concern cascade source reference is too long")
100
+ ref = value
101
+ if ref not in refs:
102
+ refs.append(ref)
103
+ if len(refs) > 30:
104
+ raise ValueError("concern cascade supports at most 30 sources")
105
+ return refs
106
+
107
+
108
+ def _cascade_intent_material(
109
+ *,
110
+ resolution_id: str,
111
+ concern_id: str,
112
+ requested: Optional[bool],
113
+ source_refs: List[str],
114
+ captured_at: str,
115
+ capture_provenance: str,
116
+ ) -> Dict[str, Any]:
117
+ if requested is not None and type(requested) is not bool:
118
+ raise ValueError("cascade intent requested flag is invalid")
119
+ if type(resolution_id) is not str or not resolution_id:
120
+ raise ValueError("cascade intent resolution id is invalid")
121
+ if type(concern_id) is not str or not concern_id:
122
+ raise ValueError("cascade intent concern id is invalid")
123
+ if type(captured_at) is not str or not captured_at:
124
+ raise ValueError("cascade intent capture time is invalid")
125
+ if capture_provenance not in {"resolution_transaction", "migration_snapshot"}:
126
+ raise ValueError("cascade intent capture provenance is invalid")
127
+ payload = {
128
+ "schema": "ColonyConcernCascadeIntentV1",
129
+ "version": 1,
130
+ "resolution_id": resolution_id,
131
+ "concern_id": concern_id,
132
+ "requested": requested,
133
+ "source_refs": _cascade_source_refs(source_refs),
134
+ "captured_at": captured_at,
135
+ "capture_provenance": capture_provenance,
136
+ }
137
+ record_digest = hashlib.sha256(
138
+ _canonical_json(payload).encode("utf-8")
139
+ ).hexdigest()
140
+ return {
141
+ **payload,
142
+ "intent_id": f"concern-cascade-intent:{record_digest}",
143
+ "record_digest": record_digest,
144
+ }
145
+
146
+
147
+ def _cascade_material(
148
+ *,
149
+ intent_id: str,
150
+ intent_digest: str,
151
+ resolution_id: str,
152
+ concern_id: str,
153
+ requested: Optional[bool],
154
+ source_refs: List[str],
155
+ source_capture: str,
156
+ status: str,
157
+ settled_sources: List[str],
158
+ failed_sources: List[str],
159
+ unexpected_result_count: int,
160
+ failure_codes: List[str],
161
+ failure_digest: Optional[str],
162
+ recorded_at: str,
163
+ ) -> Dict[str, Any]:
164
+ if requested is not None and type(requested) is not bool:
165
+ raise ValueError("cascade receipt requested flag is invalid")
166
+ if type(intent_id) is not str or not intent_id:
167
+ raise ValueError("cascade receipt intent id is invalid")
168
+ if not re.fullmatch(r"[a-f0-9]{64}", intent_digest or ""):
169
+ raise ValueError("cascade receipt intent digest is invalid")
170
+ if type(resolution_id) is not str or not resolution_id:
171
+ raise ValueError("cascade receipt resolution id is invalid")
172
+ if type(concern_id) is not str or not concern_id:
173
+ raise ValueError("cascade receipt concern id is invalid")
174
+ if source_capture not in {"resolution_transaction", "migration_snapshot"}:
175
+ raise ValueError("cascade receipt source capture is invalid")
176
+ if type(recorded_at) is not str or not recorded_at:
177
+ raise ValueError("cascade receipt record time is invalid")
178
+ refs = _cascade_source_refs(source_refs)
179
+ settled = _cascade_source_refs(settled_sources)
180
+ failed = _cascade_source_refs(failed_sources)
181
+ if set(settled) & set(failed) or set(settled + failed) - set(refs):
182
+ raise ValueError("cascade receipt source projection is invalid")
183
+ if (
184
+ settled != [ref for ref in refs if ref in set(settled)]
185
+ or failed != [ref for ref in refs if ref in set(failed)]
186
+ ):
187
+ raise ValueError("cascade receipt source projection is not canonical")
188
+ if type(unexpected_result_count) is not int or unexpected_result_count < 0:
189
+ raise ValueError("cascade receipt unexpected result count is invalid")
190
+ allowed_failure_codes = {
191
+ "duplicate_result", "execution_error", "malformed_result",
192
+ "missing_result", "not_settled", "operation_conflict",
193
+ "operation_unverified", "settler_error", "unknown_result",
194
+ }
195
+ if not isinstance(failure_codes, (list, tuple)) or any(
196
+ type(code) is not str or code not in allowed_failure_codes
197
+ for code in failure_codes
198
+ ):
199
+ raise ValueError("cascade receipt failure codes are invalid")
200
+ codes = sorted(set(failure_codes))
201
+ allowed = {
202
+ "succeeded", "failed", "not_requested", "not_applicable",
203
+ "legacy_unknown",
204
+ }
205
+ if status not in allowed:
206
+ raise ValueError("cascade receipt status is invalid")
207
+ if status == "succeeded" and (
208
+ requested is not True or settled != refs or failed
209
+ or unexpected_result_count or codes or failure_digest is not None
210
+ ):
211
+ raise ValueError("successful cascade receipt is inconsistent")
212
+ if status == "failed" and (
213
+ requested is not True or not refs or set(settled + failed) != set(refs)
214
+ or not codes or not failure_digest
215
+ ):
216
+ raise ValueError("failed cascade receipt is inconsistent")
217
+ if status == "not_requested" and requested is not False:
218
+ raise ValueError("not-requested cascade receipt is inconsistent")
219
+ if status == "not_applicable" and (requested is not True or refs):
220
+ raise ValueError("not-applicable cascade receipt is inconsistent")
221
+ if status in {"not_requested", "not_applicable", "legacy_unknown"} and (
222
+ settled or failed or unexpected_result_count or codes
223
+ or failure_digest is not None
224
+ ):
225
+ raise ValueError("non-executed cascade receipt has execution evidence")
226
+ if failure_digest is not None and not re.fullmatch(r"[a-f0-9]{64}", failure_digest):
227
+ raise ValueError("cascade receipt failure digest is invalid")
228
+ payload = {
229
+ "schema": "ColonyConcernCascadeReceiptV1",
230
+ "version": 1,
231
+ "intent_id": intent_id,
232
+ "intent_digest": intent_digest,
233
+ "resolution_id": resolution_id,
234
+ "concern_id": concern_id,
235
+ "requested": requested,
236
+ "source_refs": refs,
237
+ "source_capture": source_capture,
238
+ "status": status,
239
+ "settled_sources": settled,
240
+ "failed_sources": failed,
241
+ "unexpected_result_count": unexpected_result_count,
242
+ "failure_codes": codes,
243
+ "failure_digest": failure_digest,
244
+ "recorded_at": recorded_at,
245
+ }
246
+ record_digest = hashlib.sha256(
247
+ _canonical_json(payload).encode("utf-8")
248
+ ).hexdigest()
249
+ return {
250
+ **payload,
251
+ "cascade_receipt_id": f"concern-cascade:{record_digest}",
252
+ "record_digest": record_digest,
253
+ }
254
+
255
+
256
+ def _ordered_event_time(value: Any) -> tuple[str, int]:
257
+ """Return an exact aware timestamp and an integer ordering key."""
258
+
259
+ if type(value) is not str or not value or value != value.strip():
260
+ raise ValueError("external event occurred_at must be canonical text")
261
+ candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
262
+ try:
263
+ parsed = datetime.fromisoformat(candidate)
264
+ except ValueError as exc:
265
+ raise ValueError("external event occurred_at is invalid") from exc
266
+ if parsed.tzinfo is None:
267
+ raise ValueError("external event occurred_at requires a timezone")
268
+ delta = parsed.astimezone(timezone.utc) - _UTC_EPOCH
269
+ microseconds = (
270
+ delta.days * 86_400_000_000
271
+ + delta.seconds * 1_000_000
272
+ + delta.microseconds
273
+ )
274
+ return value, microseconds
275
+
276
+
277
+ def workspace_mode() -> str:
278
+ """Effective COLONY_WORKSPACE: explicit env > autonomy preset > off.
279
+
280
+ An explicitly-set invalid value falls back to "off", exactly as the
281
+ legacy reader did; the preset only fills the unset case."""
282
+ from apsimo.util.autonomy_preset import resolve
283
+ return resolve("COLONY_WORKSPACE", ("off", "shadow", "live"), "off")
284
+
285
+
286
+ def workspace_enabled() -> bool:
287
+ return workspace_mode() in ("shadow", "live")
288
+
289
+
290
+ def _capacity() -> int:
291
+ try:
292
+ return int(os.environ.get("COLONY_WORKSPACE_CAPACITY", "24"))
293
+ except ValueError:
294
+ return 24
295
+
296
+
297
+ def _decay_half_life_hours() -> float:
298
+ try:
299
+ return float(os.environ.get("COLONY_WORKSPACE_HALFLIFE_HOURS", "12"))
300
+ except ValueError:
301
+ return 12.0
302
+
303
+
304
+ def _evict_floor() -> float:
305
+ try:
306
+ return float(os.environ.get("COLONY_WORKSPACE_EVICT_FLOOR", "0.05"))
307
+ except ValueError:
308
+ return 0.05
309
+
310
+
311
+ def _thought_budget() -> int:
312
+ try:
313
+ return int(os.environ.get("COLONY_WORKSPACE_THOUGHT_BUDGET", "8"))
314
+ except ValueError:
315
+ return 8
316
+
317
+
318
+ def _resolved_ttl_hours() -> float:
319
+ """How long a resolved concern suppresses re-raising the same dedup_key.
320
+
321
+ A concern is often raised FROM a still-open source by a periodic ingest;
322
+ once someone resolves the concern, recreating it on the very next tick
323
+ makes the resolve cosmetic. Within this window the resolved row answers
324
+ the upsert instead. If the source is genuinely still open after the
325
+ window, the concern legitimately returns."""
326
+ try:
327
+ return float(os.environ.get("COLONY_WORKSPACE_RESOLVED_TTL_HOURS", "24"))
328
+ except ValueError:
329
+ return 24.0
330
+
331
+
332
+ def in_sleep_window(now: Optional[datetime] = None) -> bool:
333
+ """COLONY_SLEEP_WINDOW = 'HH:MM-HH:MM' in the deployment's local time
334
+ (uses the process tz). Empty disables. Wrap-around (22:00-06:00) ok."""
335
+ win = os.environ.get("COLONY_SLEEP_WINDOW", "").strip()
336
+ if not win or "-" not in win:
337
+ return False
338
+ try:
339
+ a, b = win.split("-", 1)
340
+ ah, am = [int(x) for x in a.split(":")]
341
+ bh, bm = [int(x) for x in b.split(":")]
342
+ except ValueError:
343
+ return False
344
+ now = now or datetime.now()
345
+ cur = now.hour * 60 + now.minute
346
+ start, end = ah * 60 + am, bh * 60 + bm
347
+ if start <= end:
348
+ return start <= cur < end
349
+ return cur >= start or cur < end # wraps midnight
350
+
351
+
352
+ @dataclass
353
+ class Concern:
354
+ concern_id: str
355
+ kind: str
356
+ summary: str
357
+ salience: float
358
+ sources: List[str] = field(default_factory=list)
359
+ thoughts_spent: int = 0
360
+ max_thoughts: int = 8
361
+ status: str = "active" # active | resolved | evicted
362
+ last_note: str = ""
363
+ created_at: float = 0.0
364
+ last_touched: float = 0.0
365
+ last_thought_at: Optional[float] = None
366
+ # memory ids the thinker actually consulted while reasoning about this
367
+ # concern (a measured provenance link, most-recent first) -- NOT a
368
+ # render-time similarity guess. Empty until it has been thought about.
369
+ memory_refs: List[str] = field(default_factory=list)
370
+ dedup_key: str = ""
371
+ subject_person_id: str = ""
372
+ viewer_scope: str = "owner"
373
+ shareability: str = "owner_private"
374
+ last_material_event_id: str = ""
375
+ last_material_event_seq: int = 0
376
+ last_material_event_at: str = ""
377
+ last_material_digest: str = ""
378
+ producer_name: str = "server"
379
+ producer_mode: str = "live"
380
+ producer_revision: str = "workspace-concern-v2"
381
+ promoted_at: str = ""
382
+ promotion_ref: str = ""
383
+
384
+ def visible_to(
385
+ self,
386
+ *,
387
+ viewer_person_id: str,
388
+ owner_person_id: str,
389
+ audiences: set[str] | frozenset[str] = frozenset(),
390
+ ) -> bool:
391
+ """Return whether this concern may be projected to one viewer.
392
+
393
+ This is deliberately small and deterministic. Event reduction stores
394
+ the scope; ranking or an LLM can never make a private concern visible.
395
+ """
396
+
397
+ viewer = str(viewer_person_id or "").strip()
398
+ owner = str(owner_person_id or "").strip()
399
+ if not viewer:
400
+ return False
401
+ if owner and viewer == owner:
402
+ return True
403
+ if self.shareability == "public":
404
+ return "global" in audiences
405
+ if self.shareability == "shared":
406
+ return "shared" in audiences
407
+ if self.shareability == "subject_private":
408
+ return bool(self.subject_person_id and viewer == self.subject_person_id)
409
+ return False
410
+
411
+ def public(self) -> Dict[str, Any]:
412
+ return {
413
+ "concern_id": self.concern_id, "kind": self.kind,
414
+ "summary": self.summary, "salience": round(self.salience, 4),
415
+ "sources": self.sources, "thoughts_spent": self.thoughts_spent,
416
+ "max_thoughts": self.max_thoughts, "status": self.status,
417
+ "last_note": self.last_note, "created_at": self.created_at,
418
+ "last_touched": self.last_touched,
419
+ "last_thought_at": self.last_thought_at,
420
+ "memory_refs": self.memory_refs,
421
+ "subject_person_id": self.subject_person_id,
422
+ "viewer_scope": self.viewer_scope,
423
+ "shareability": self.shareability,
424
+ "last_material_event_id": self.last_material_event_id,
425
+ "last_material_event_seq": self.last_material_event_seq,
426
+ "last_material_event_at": self.last_material_event_at,
427
+ "producer_name": self.producer_name,
428
+ "producer_mode": self.producer_mode,
429
+ "producer_revision": self.producer_revision,
430
+ "promoted_at": self.promoted_at,
431
+ "promotion_ref": self.promotion_ref,
432
+ }
433
+
434
+
435
+ class ConcernStore:
436
+ def __init__(self, db_path: str) -> None:
437
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
438
+ self._conn.row_factory = sqlite3.Row
439
+ self._conn.execute("PRAGMA journal_mode=WAL")
440
+ self._lock = threading.Lock()
441
+ self._conn.executescript(
442
+ """
443
+ CREATE TABLE IF NOT EXISTS concerns (
444
+ concern_id TEXT PRIMARY KEY,
445
+ kind TEXT NOT NULL,
446
+ summary TEXT NOT NULL,
447
+ salience REAL NOT NULL,
448
+ sources TEXT,
449
+ dedup_key TEXT,
450
+ thoughts_spent INTEGER DEFAULT 0,
451
+ max_thoughts INTEGER DEFAULT 8,
452
+ status TEXT DEFAULT 'active',
453
+ last_note TEXT,
454
+ created_at REAL NOT NULL,
455
+ last_touched REAL NOT NULL,
456
+ last_thought_at REAL
457
+ );
458
+ CREATE INDEX IF NOT EXISTS idx_concern_status_sal
459
+ ON concerns(status, salience);
460
+ CREATE INDEX IF NOT EXISTS idx_concern_dedup ON concerns(dedup_key);
461
+ CREATE TABLE IF NOT EXISTS concern_event_cursors (
462
+ consumer_id TEXT PRIMARY KEY,
463
+ last_seq INTEGER NOT NULL,
464
+ bootstrap_mode TEXT NOT NULL,
465
+ initialized_at TEXT NOT NULL,
466
+ updated_at TEXT NOT NULL,
467
+ last_error TEXT
468
+ );
469
+ CREATE TABLE IF NOT EXISTS concern_event_receipts (
470
+ consumer_id TEXT NOT NULL,
471
+ event_seq INTEGER NOT NULL,
472
+ event_id TEXT NOT NULL,
473
+ event_type TEXT NOT NULL,
474
+ material_digest TEXT NOT NULL,
475
+ disposition TEXT NOT NULL,
476
+ concern_id TEXT,
477
+ reason TEXT,
478
+ processed_at TEXT NOT NULL,
479
+ PRIMARY KEY (consumer_id, event_seq),
480
+ UNIQUE (consumer_id, event_id)
481
+ );
482
+ CREATE INDEX IF NOT EXISTS idx_concern_event_receipts_concern
483
+ ON concern_event_receipts(concern_id, event_seq);
484
+ CREATE TABLE IF NOT EXISTS concern_external_event_watermarks (
485
+ consumer_id TEXT NOT NULL,
486
+ dedup_key TEXT NOT NULL,
487
+ occurred_at TEXT NOT NULL,
488
+ occurred_at_us INTEGER NOT NULL,
489
+ operation TEXT NOT NULL,
490
+ material_digest TEXT NOT NULL,
491
+ event_id TEXT NOT NULL,
492
+ event_seq INTEGER NOT NULL,
493
+ updated_at TEXT NOT NULL,
494
+ PRIMARY KEY (consumer_id,dedup_key)
495
+ );
496
+ CREATE INDEX IF NOT EXISTS idx_concern_external_watermarks_consumer
497
+ ON concern_external_event_watermarks(consumer_id,event_seq);
498
+ CREATE TABLE IF NOT EXISTS concern_event_gaps (
499
+ gap_id INTEGER PRIMARY KEY AUTOINCREMENT,
500
+ consumer_id TEXT NOT NULL,
501
+ prior_cursor INTEGER NOT NULL,
502
+ resumed_at INTEGER NOT NULL,
503
+ reason TEXT NOT NULL,
504
+ acknowledged_at TEXT NOT NULL
505
+ );
506
+ CREATE TABLE IF NOT EXISTS concern_settlements (
507
+ settlement_ref TEXT PRIMARY KEY,
508
+ concern_id TEXT NOT NULL UNIQUE,
509
+ settlement_kind TEXT NOT NULL,
510
+ evidence_refs TEXT NOT NULL,
511
+ reason TEXT NOT NULL,
512
+ settled_at TEXT NOT NULL
513
+ );
514
+ CREATE TABLE IF NOT EXISTS concern_resolutions (
515
+ resolution_id TEXT PRIMARY KEY,
516
+ concern_id TEXT NOT NULL UNIQUE,
517
+ outcome TEXT,
518
+ note TEXT NOT NULL,
519
+ note_digest TEXT NOT NULL,
520
+ cascade INTEGER,
521
+ resolved_by TEXT,
522
+ resolved_at TEXT NOT NULL,
523
+ provenance TEXT NOT NULL,
524
+ record_digest TEXT NOT NULL UNIQUE
525
+ );
526
+ CREATE TABLE IF NOT EXISTS concern_resolution_cascade_intents (
527
+ intent_id TEXT PRIMARY KEY,
528
+ resolution_id TEXT NOT NULL UNIQUE,
529
+ concern_id TEXT NOT NULL UNIQUE,
530
+ intent_json TEXT NOT NULL,
531
+ record_digest TEXT NOT NULL UNIQUE
532
+ );
533
+ CREATE TABLE IF NOT EXISTS concern_resolution_cascade_receipts (
534
+ cascade_receipt_id TEXT PRIMARY KEY,
535
+ intent_id TEXT NOT NULL UNIQUE,
536
+ resolution_id TEXT NOT NULL UNIQUE,
537
+ concern_id TEXT NOT NULL UNIQUE,
538
+ receipt_json TEXT NOT NULL,
539
+ record_digest TEXT NOT NULL UNIQUE
540
+ );
541
+ """
542
+ )
543
+ # migration: memory_refs was added after first ship; add it to older
544
+ # DBs. ADD COLUMN is backward-compatible (existing rows read NULL).
545
+ try:
546
+ self._conn.execute("ALTER TABLE concerns ADD COLUMN memory_refs TEXT")
547
+ except sqlite3.OperationalError:
548
+ pass # column already present
549
+ for name, declaration in (
550
+ ("subject_person_id", "TEXT"),
551
+ ("viewer_scope", "TEXT DEFAULT 'owner'"),
552
+ ("shareability", "TEXT DEFAULT 'owner_private'"),
553
+ ("last_material_event_id", "TEXT"),
554
+ ("last_material_event_seq", "INTEGER DEFAULT 0"),
555
+ ("last_material_event_at", "TEXT"),
556
+ ("last_material_digest", "TEXT"),
557
+ ("producer_name", "TEXT DEFAULT 'legacy'"),
558
+ ("producer_mode", "TEXT DEFAULT 'unknown'"),
559
+ ("producer_revision", "TEXT DEFAULT 'unversioned'"),
560
+ ("promoted_at", "TEXT"),
561
+ ("promotion_ref", "TEXT"),
562
+ ):
563
+ try:
564
+ self._conn.execute(
565
+ f"ALTER TABLE concerns ADD COLUMN {name} {declaration}"
566
+ )
567
+ except sqlite3.OperationalError:
568
+ pass
569
+ try:
570
+ self._conn.execute(
571
+ "ALTER TABLE concern_resolutions ADD COLUMN cascade INTEGER"
572
+ )
573
+ except sqlite3.OperationalError:
574
+ pass
575
+ self._migrate_legacy_resolutions()
576
+ self._migrate_resolution_cascade_records()
577
+ self._conn.commit()
578
+
579
+ def _migrate_legacy_resolutions(self) -> None:
580
+ """Freeze truthful receipts for terminal rows predating owner receipts.
581
+
582
+ Legacy rows do not contain an exact outcome or resolver. Persisting
583
+ explicit NULLs keeps those facts unknown instead of letting a later
584
+ caller accidentally claim its request was the original decision.
585
+ """
586
+
587
+ rows = self._conn.execute(
588
+ """SELECT concern_id,last_note,last_touched,created_at
589
+ FROM concerns WHERE status='resolved'
590
+ AND NOT EXISTS (
591
+ SELECT 1 FROM concern_resolutions r
592
+ WHERE r.concern_id=concerns.concern_id
593
+ )"""
594
+ ).fetchall()
595
+ for row in rows:
596
+ stamp = (
597
+ row["last_touched"]
598
+ if row["last_touched"] is not None
599
+ else row["created_at"]
600
+ )
601
+ try:
602
+ resolved_at = datetime.fromtimestamp(
603
+ float(stamp), timezone.utc,
604
+ ).isoformat()
605
+ except (OverflowError, TypeError, ValueError):
606
+ resolved_at = _UTC_EPOCH.isoformat()
607
+ material = _resolution_material(
608
+ concern_id=str(row["concern_id"]),
609
+ outcome=None,
610
+ note=str(row["last_note"] or ""),
611
+ cascade=None,
612
+ resolved_by=None,
613
+ resolved_at=resolved_at,
614
+ provenance=_LEGACY_RESOLUTION_PROVENANCE,
615
+ )
616
+ self._conn.execute(
617
+ """INSERT OR IGNORE INTO concern_resolutions
618
+ (resolution_id,concern_id,outcome,note,note_digest,
619
+ cascade,resolved_by,resolved_at,provenance,record_digest)
620
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
621
+ (
622
+ material["resolution_id"], material["concern_id"],
623
+ material["outcome"], material["note"],
624
+ material["note_digest"], material["cascade"],
625
+ material["resolved_by"], material["resolved_at"],
626
+ material["provenance"], material["record_digest"],
627
+ ),
628
+ )
629
+
630
+ @staticmethod
631
+ def _stored_source_refs(value: Any) -> List[str]:
632
+ try:
633
+ decoded = json.loads(value or "[]")
634
+ except (TypeError, ValueError) as exc:
635
+ raise ValueError("concern sources are invalid") from exc
636
+ return _cascade_source_refs(decoded)
637
+
638
+ def _insert_cascade_intent(self, intent: Mapping[str, Any]) -> None:
639
+ self._conn.execute(
640
+ """INSERT INTO concern_resolution_cascade_intents
641
+ (intent_id,resolution_id,concern_id,intent_json,record_digest)
642
+ VALUES (?,?,?,?,?)""",
643
+ (
644
+ intent["intent_id"], intent["resolution_id"],
645
+ intent["concern_id"], _canonical_json(intent),
646
+ intent["record_digest"],
647
+ ),
648
+ )
649
+
650
+ def _insert_cascade_receipt(self, receipt: Mapping[str, Any]) -> None:
651
+ self._conn.execute(
652
+ """INSERT INTO concern_resolution_cascade_receipts
653
+ (cascade_receipt_id,intent_id,resolution_id,concern_id,
654
+ receipt_json,record_digest)
655
+ VALUES (?,?,?,?,?,?)""",
656
+ (
657
+ receipt["cascade_receipt_id"], receipt["intent_id"],
658
+ receipt["resolution_id"], receipt["concern_id"],
659
+ _canonical_json(receipt), receipt["record_digest"],
660
+ ),
661
+ )
662
+
663
+ def _migrate_resolution_cascade_records(self) -> None:
664
+ """Make prior terminal rows explicit without inventing outcomes.
665
+
666
+ An intent created here is marked as a migration-time source snapshot,
667
+ then receives a terminal ``legacy_unknown`` receipt unless the base
668
+ decision proves cascade was not requested. An existing transactional
669
+ intent without a terminal receipt is deliberately left pending: that
670
+ shape means a current-version process stopped between its decision and
671
+ source-settlement phases.
672
+ """
673
+
674
+ rows = self._conn.execute(
675
+ """SELECT r.*,c.sources AS concern_sources
676
+ FROM concern_resolutions r
677
+ LEFT JOIN concerns c ON c.concern_id=r.concern_id
678
+ ORDER BY r.resolved_at,r.resolution_id"""
679
+ ).fetchall()
680
+ for row in rows:
681
+ base = self._resolution_receipt(row)
682
+ intent_row = self._conn.execute(
683
+ """SELECT * FROM concern_resolution_cascade_intents
684
+ WHERE resolution_id=?""",
685
+ (base["resolution_id"],),
686
+ ).fetchone()
687
+ if intent_row is None:
688
+ refs = self._stored_source_refs(row["concern_sources"])
689
+ intent = _cascade_intent_material(
690
+ resolution_id=base["resolution_id"],
691
+ concern_id=base["concern_id"],
692
+ requested=base["cascade"],
693
+ source_refs=refs,
694
+ captured_at=datetime.now(timezone.utc).isoformat(),
695
+ capture_provenance="migration_snapshot",
696
+ )
697
+ self._insert_cascade_intent(intent)
698
+ else:
699
+ intent = self._cascade_intent_receipt(intent_row, base)
700
+
701
+ terminal = self._conn.execute(
702
+ """SELECT * FROM concern_resolution_cascade_receipts
703
+ WHERE intent_id=?""",
704
+ (intent["intent_id"],),
705
+ ).fetchone()
706
+ if terminal is not None:
707
+ self._cascade_receipt(terminal, base, intent)
708
+ continue
709
+ if intent["capture_provenance"] != "migration_snapshot":
710
+ continue
711
+ status = (
712
+ "not_requested" if intent["requested"] is False
713
+ else "legacy_unknown"
714
+ )
715
+ receipt = _cascade_material(
716
+ intent_id=intent["intent_id"],
717
+ intent_digest=intent["record_digest"],
718
+ resolution_id=base["resolution_id"],
719
+ concern_id=base["concern_id"],
720
+ requested=intent["requested"],
721
+ source_refs=intent["source_refs"],
722
+ source_capture=intent["capture_provenance"],
723
+ status=status,
724
+ settled_sources=[],
725
+ failed_sources=[],
726
+ unexpected_result_count=0,
727
+ failure_codes=[],
728
+ failure_digest=None,
729
+ recorded_at=datetime.now(timezone.utc).isoformat(),
730
+ )
731
+ self._insert_cascade_receipt(receipt)
732
+
733
+ def _row(self, r: sqlite3.Row) -> Concern:
734
+ keys = r.keys()
735
+ mrefs = json.loads(r["memory_refs"] or "[]") if "memory_refs" in keys else []
736
+ return Concern(
737
+ concern_id=r["concern_id"], kind=r["kind"], summary=r["summary"],
738
+ salience=r["salience"], sources=json.loads(r["sources"] or "[]"),
739
+ thoughts_spent=r["thoughts_spent"] or 0,
740
+ max_thoughts=r["max_thoughts"] or 8, status=r["status"],
741
+ last_note=r["last_note"] or "", created_at=r["created_at"],
742
+ last_touched=r["last_touched"], last_thought_at=r["last_thought_at"],
743
+ memory_refs=mrefs,
744
+ dedup_key=(r["dedup_key"] or "") if "dedup_key" in keys else "",
745
+ subject_person_id=(r["subject_person_id"] or "")
746
+ if "subject_person_id" in keys else "",
747
+ viewer_scope=(r["viewer_scope"] or "owner")
748
+ if "viewer_scope" in keys else "owner",
749
+ shareability=(r["shareability"] or "owner_private")
750
+ if "shareability" in keys else "owner_private",
751
+ last_material_event_id=(r["last_material_event_id"] or "")
752
+ if "last_material_event_id" in keys else "",
753
+ last_material_event_seq=int(r["last_material_event_seq"] or 0)
754
+ if "last_material_event_seq" in keys else 0,
755
+ last_material_event_at=(r["last_material_event_at"] or "")
756
+ if "last_material_event_at" in keys else "",
757
+ last_material_digest=(r["last_material_digest"] or "")
758
+ if "last_material_digest" in keys else "",
759
+ producer_name=(r["producer_name"] or "legacy")
760
+ if "producer_name" in keys else "legacy",
761
+ producer_mode=(r["producer_mode"] or "unknown")
762
+ if "producer_mode" in keys else "unknown",
763
+ producer_revision=(r["producer_revision"] or "unversioned")
764
+ if "producer_revision" in keys else "unversioned",
765
+ promoted_at=(r["promoted_at"] or "")
766
+ if "promoted_at" in keys else "",
767
+ promotion_ref=(r["promotion_ref"] or "")
768
+ if "promotion_ref" in keys else "")
769
+
770
+ def upsert(self, *, kind: str, summary: str, salience: float,
771
+ dedup_key: str, sources: List[str],
772
+ max_thoughts: int,
773
+ producer_name: str = "server",
774
+ producer_mode: str = "live",
775
+ producer_revision: str = "workspace-concern-v2") -> Concern:
776
+ producer = str(producer_name or "unknown").strip()[:64]
777
+ mode = str(producer_mode or "unknown").strip().lower()[:16]
778
+ revision = str(producer_revision or "unversioned").strip()[:128]
779
+ if mode not in {"off", "shadow", "live", "unknown"}:
780
+ mode = "unknown"
781
+ now = time.time()
782
+ with self._lock:
783
+ r = self._conn.execute(
784
+ "SELECT * FROM concerns WHERE dedup_key=? AND status='active'",
785
+ (dedup_key,)).fetchone()
786
+ if r is not None:
787
+ merged = list(dict.fromkeys(
788
+ json.loads(r["sources"] or "[]") + sources))[:30]
789
+ new_sal = min(1.0, max(r["salience"], salience) + 0.05)
790
+ self._conn.execute(
791
+ "UPDATE concerns SET salience=?, sources=?, "
792
+ "last_touched=?,producer_name=?,producer_mode=?,"
793
+ "producer_revision=?,promoted_at=NULL,promotion_ref=NULL "
794
+ "WHERE concern_id=?",
795
+ (new_sal, json.dumps(merged), now, producer, mode,
796
+ revision, r["concern_id"]))
797
+ self._conn.commit()
798
+ return self._row(self._conn.execute(
799
+ "SELECT * FROM concerns WHERE concern_id=?",
800
+ (r["concern_id"],)).fetchone())
801
+ # Recently-resolved same key: return the resolved row untouched
802
+ # instead of minting a fresh concern, so a resolve sticks even
803
+ # while the underlying source is still open (see _resolved_ttl).
804
+ r2 = self._conn.execute(
805
+ "SELECT * FROM concerns WHERE dedup_key=? AND "
806
+ "status='resolved' ORDER BY last_touched DESC LIMIT 1",
807
+ (dedup_key,)).fetchone()
808
+ if r2 is not None and (now - (r2["last_touched"] or 0)) <\
809
+ _resolved_ttl_hours() * 3600.0:
810
+ return self._row(r2)
811
+ cid = f"c-{uuid.uuid4().hex[:12]}"
812
+ self._conn.execute(
813
+ "INSERT INTO concerns (concern_id,kind,summary,salience,"
814
+ "sources,dedup_key,max_thoughts,created_at,last_touched,"
815
+ "producer_name,producer_mode,producer_revision)"
816
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
817
+ (cid, kind, summary, min(1.0, salience), json.dumps(sources),
818
+ dedup_key, max_thoughts, now, now, producer, mode, revision))
819
+ self._conn.commit()
820
+ return self._row(self._conn.execute(
821
+ "SELECT * FROM concerns WHERE concern_id=?", (cid,)).fetchone())
822
+
823
+ def event_cursor(self, consumer_id: str) -> Optional[int]:
824
+ with self._lock:
825
+ row = self._conn.execute(
826
+ "SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
827
+ (consumer_id,),
828
+ ).fetchone()
829
+ return int(row["last_seq"]) if row is not None else None
830
+
831
+ def initialize_event_cursor(
832
+ self,
833
+ consumer_id: str,
834
+ sequence: int,
835
+ *,
836
+ bootstrap_mode: str,
837
+ ) -> int:
838
+ """Create a durable reducer cursor without overwriting an existing one."""
839
+
840
+ now = datetime.now(timezone.utc).isoformat()
841
+ with self._lock:
842
+ self._conn.execute(
843
+ """INSERT OR IGNORE INTO concern_event_cursors
844
+ (consumer_id,last_seq,bootstrap_mode,initialized_at,updated_at,last_error)
845
+ VALUES (?,?,?,?,?,NULL)""",
846
+ (consumer_id, max(0, int(sequence)), bootstrap_mode, now, now),
847
+ )
848
+ self._conn.commit()
849
+ row = self._conn.execute(
850
+ "SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
851
+ (consumer_id,),
852
+ ).fetchone()
853
+ return int(row["last_seq"])
854
+
855
+ def set_event_error(self, consumer_id: str, message: str) -> None:
856
+ with self._lock:
857
+ self._conn.execute(
858
+ "UPDATE concern_event_cursors SET last_error=?, updated_at=? "
859
+ "WHERE consumer_id=?",
860
+ (str(message or "")[:200], datetime.now(timezone.utc).isoformat(),
861
+ consumer_id),
862
+ )
863
+ self._conn.commit()
864
+
865
+ def acknowledge_event_gap(
866
+ self,
867
+ consumer_id: str,
868
+ *,
869
+ prior_cursor: int,
870
+ resume_after: int,
871
+ reason: str,
872
+ ) -> int:
873
+ """Audit and advance across journal records lost to retention.
874
+
875
+ This is never automatic under the default stop policy. It preserves
876
+ the gap as evidence instead of pretending the missing records were
877
+ processed.
878
+ """
879
+
880
+ now = datetime.now(timezone.utc).isoformat()
881
+ with self._lock:
882
+ current = self._conn.execute(
883
+ "SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
884
+ (consumer_id,),
885
+ ).fetchone()
886
+ if current is None or int(current["last_seq"]) != int(prior_cursor):
887
+ raise ValueError("event cursor changed before gap acknowledgement")
888
+ if int(resume_after) <= int(prior_cursor):
889
+ raise ValueError("gap resume cursor must advance")
890
+ self._conn.execute("BEGIN IMMEDIATE")
891
+ try:
892
+ self._conn.execute(
893
+ """INSERT INTO concern_event_gaps
894
+ (consumer_id,prior_cursor,resumed_at,reason,acknowledged_at)
895
+ VALUES (?,?,?,?,?)""",
896
+ (consumer_id, int(prior_cursor), int(resume_after),
897
+ str(reason or "journal_retention_gap")[:200], now),
898
+ )
899
+ self._conn.execute(
900
+ """UPDATE concern_event_cursors
901
+ SET last_seq=?,updated_at=?,last_error=NULL
902
+ WHERE consumer_id=?""",
903
+ (int(resume_after), now, consumer_id),
904
+ )
905
+ self._conn.commit()
906
+ except Exception:
907
+ self._conn.rollback()
908
+ raise
909
+ return int(resume_after)
910
+
911
+ def event_reducer_status(self, consumer_id: str) -> Dict[str, Any]:
912
+ with self._lock:
913
+ cursor = self._conn.execute(
914
+ "SELECT * FROM concern_event_cursors WHERE consumer_id=?",
915
+ (consumer_id,),
916
+ ).fetchone()
917
+ rows = self._conn.execute(
918
+ """SELECT disposition,COUNT(*) AS n,MAX(event_seq) AS last_seq
919
+ FROM concern_event_receipts WHERE consumer_id=?
920
+ GROUP BY disposition""",
921
+ (consumer_id,),
922
+ ).fetchall()
923
+ gaps = self._conn.execute(
924
+ "SELECT COUNT(*) AS n,MAX(resumed_at) AS last_resume "
925
+ "FROM concern_event_gaps WHERE consumer_id=?",
926
+ (consumer_id,),
927
+ ).fetchone()
928
+ watermarks = self._conn.execute(
929
+ "SELECT COUNT(*) AS n,MAX(event_seq) AS last_seq "
930
+ "FROM concern_external_event_watermarks WHERE consumer_id=?",
931
+ (consumer_id,),
932
+ ).fetchone()
933
+ latest_watermark = self._conn.execute(
934
+ "SELECT occurred_at FROM concern_external_event_watermarks "
935
+ "WHERE consumer_id=? ORDER BY occurred_at_us DESC LIMIT 1",
936
+ (consumer_id,),
937
+ ).fetchone()
938
+ return {
939
+ "initialized": cursor is not None,
940
+ "cursor": int(cursor["last_seq"]) if cursor is not None else None,
941
+ "bootstrap_mode": cursor["bootstrap_mode"] if cursor is not None else None,
942
+ "initialized_at": cursor["initialized_at"] if cursor is not None else None,
943
+ "updated_at": cursor["updated_at"] if cursor is not None else None,
944
+ "last_error": cursor["last_error"] if cursor is not None else None,
945
+ "gaps": {
946
+ "count": int(gaps["n"] or 0),
947
+ "last_resume": int(gaps["last_resume"] or 0),
948
+ },
949
+ "event_time_watermarks": {
950
+ "count": int(watermarks["n"] or 0),
951
+ "last_event_seq": int(watermarks["last_seq"] or 0),
952
+ "latest_occurred_at": (
953
+ latest_watermark["occurred_at"]
954
+ if latest_watermark is not None else None
955
+ ),
956
+ },
957
+ "dispositions": {
958
+ row["disposition"]: {
959
+ "count": int(row["n"]),
960
+ "last_seq": int(row["last_seq"]),
961
+ }
962
+ for row in rows
963
+ },
964
+ }
965
+
966
+ def apply_event(
967
+ self,
968
+ *,
969
+ consumer_id: str,
970
+ event_seq: int,
971
+ event_id: str,
972
+ event_type: str,
973
+ material_digest: str,
974
+ projection: Optional[Mapping[str, Any]],
975
+ skip_reason: str = "",
976
+ ) -> Dict[str, Any]:
977
+ """Atomically reduce one journal event and advance its durable cursor.
978
+
979
+ A receipt, concern mutation, and cursor update share one transaction.
980
+ Replaying after any crash therefore cannot duplicate a concern or
981
+ inflate salience for the same material state.
982
+ """
983
+
984
+ sequence = int(event_seq)
985
+ if sequence < 1:
986
+ raise ValueError("event sequence must be positive")
987
+ consumer = str(consumer_id or "").strip()[:128]
988
+ native_id = str(event_id or "").strip()[:128]
989
+ native_type = str(event_type or "unknown").strip()[:128]
990
+ digest = str(material_digest or "").strip()[:128]
991
+ if not consumer or not native_id or not native_type or not digest:
992
+ raise ValueError("event authority fields are required")
993
+
994
+ now_iso = datetime.now(timezone.utc).isoformat()
995
+ with self._lock:
996
+ existing = self._conn.execute(
997
+ """SELECT event_seq,event_type,material_digest,disposition,concern_id
998
+ FROM concern_event_receipts
999
+ WHERE consumer_id=? AND event_id=?""",
1000
+ (consumer, native_id),
1001
+ ).fetchone()
1002
+ if existing is not None:
1003
+ if (int(existing["event_seq"]) != sequence
1004
+ or existing["event_type"] != native_type
1005
+ or existing["material_digest"] != digest):
1006
+ raise ValueError("event id was replayed with conflicting content")
1007
+ current = self._conn.execute(
1008
+ "SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
1009
+ (consumer,),
1010
+ ).fetchone()
1011
+ return {
1012
+ "status": "duplicate_event",
1013
+ "disposition": existing["disposition"],
1014
+ "concern_id": existing["concern_id"],
1015
+ "cursor": int(current["last_seq"]) if current is not None else None,
1016
+ }
1017
+
1018
+ cursor_row = self._conn.execute(
1019
+ "SELECT last_seq FROM concern_event_cursors WHERE consumer_id=?",
1020
+ (consumer,),
1021
+ ).fetchone()
1022
+ if cursor_row is None:
1023
+ raise ValueError("event reducer cursor is not initialized")
1024
+ cursor = int(cursor_row["last_seq"])
1025
+ if sequence != cursor + 1:
1026
+ raise ValueError("event sequence is not cursor-contiguous")
1027
+
1028
+ try:
1029
+ self._conn.execute("BEGIN IMMEDIATE")
1030
+ disposition = "skipped"
1031
+ concern_id: Optional[str] = None
1032
+ reason = str(skip_reason or "")[:100]
1033
+
1034
+ if projection is not None:
1035
+ external_projection = (
1036
+ projection.get("external_event_projection") is True
1037
+ )
1038
+ operation_raw = projection.get("operation") or "upsert"
1039
+ dedup_raw = projection.get("dedup_key") or ""
1040
+ if external_projection:
1041
+ if (
1042
+ type(operation_raw) is not str
1043
+ or operation_raw not in {"upsert", "resolve"}
1044
+ or type(dedup_raw) is not str
1045
+ or not dedup_raw
1046
+ or dedup_raw != dedup_raw.strip()
1047
+ or len(dedup_raw) > 200
1048
+ or projection.get("producer_name")
1049
+ != "external_event_concerns"
1050
+ ):
1051
+ raise ValueError(
1052
+ "external event projection marker is invalid"
1053
+ )
1054
+ operation = str(operation_raw)
1055
+ dedup_key = str(dedup_raw)[:200]
1056
+ if not dedup_key:
1057
+ raise ValueError("event projection requires a dedup key")
1058
+ row = self._conn.execute(
1059
+ "SELECT * FROM concerns WHERE dedup_key=? AND status='active'",
1060
+ (dedup_key,),
1061
+ ).fetchone()
1062
+
1063
+ apply_projection = True
1064
+ advance_watermark = False
1065
+ occurred_at = str(
1066
+ projection.get("occurred_at") or ""
1067
+ )[:64]
1068
+ occurred_at_us = 0
1069
+ if external_projection:
1070
+ occurred_at, occurred_at_us = _ordered_event_time(
1071
+ projection.get("occurred_at")
1072
+ )
1073
+ if len(occurred_at) > 64:
1074
+ raise ValueError(
1075
+ "external event occurred_at exceeds 64 characters"
1076
+ )
1077
+ watermark = self._conn.execute(
1078
+ "SELECT * FROM concern_external_event_watermarks "
1079
+ "WHERE consumer_id=? AND dedup_key=?",
1080
+ (consumer, dedup_key),
1081
+ ).fetchone()
1082
+ if watermark is None:
1083
+ advance_watermark = True
1084
+ elif occurred_at_us < int(watermark["occurred_at_us"]):
1085
+ disposition = "external_stale_event"
1086
+ reason = "external_event_time_older_than_watermark"
1087
+ apply_projection = False
1088
+ elif occurred_at_us == int(watermark["occurred_at_us"]):
1089
+ if (
1090
+ operation == watermark["operation"]
1091
+ and digest == watermark["material_digest"]
1092
+ ):
1093
+ disposition = "duplicate_material"
1094
+ reason = "external_event_time_equal_material_replay"
1095
+ else:
1096
+ disposition = "external_event_time_conflict"
1097
+ reason = (
1098
+ "external_event_time_equal_to_watermark_conflict"
1099
+ )
1100
+ apply_projection = False
1101
+ else:
1102
+ advance_watermark = True
1103
+ if not apply_projection:
1104
+ reference = row
1105
+ if reference is None:
1106
+ reference = self._conn.execute(
1107
+ "SELECT concern_id FROM concerns "
1108
+ "WHERE dedup_key=? ORDER BY last_touched DESC "
1109
+ "LIMIT 1",
1110
+ (dedup_key,),
1111
+ ).fetchone()
1112
+ concern_id = (
1113
+ reference["concern_id"]
1114
+ if reference is not None else None
1115
+ )
1116
+
1117
+ if apply_projection and operation == "resolve":
1118
+ if row is None:
1119
+ disposition = "resolve_noop"
1120
+ else:
1121
+ concern_id = row["concern_id"]
1122
+ self._conn.execute(
1123
+ """UPDATE concerns SET status='resolved',salience=0.0,
1124
+ last_note=?,last_touched=?,last_material_event_id=?,
1125
+ last_material_event_seq=?,last_material_event_at=?,
1126
+ last_material_digest=? WHERE concern_id=?""",
1127
+ (str(projection.get("note") or "resolved by source event")[:500],
1128
+ time.time(), native_id, sequence,
1129
+ occurred_at, digest, concern_id),
1130
+ )
1131
+ disposition = "resolved"
1132
+ elif apply_projection and operation == "upsert":
1133
+ summary = str(projection.get("summary") or native_type)[:300]
1134
+ kind = str(projection.get("kind") or "thread")
1135
+ kind = kind if kind in CONCERN_KINDS else "thread"
1136
+ salience = max(0.0, min(1.0, float(
1137
+ projection.get("salience", 0.5))))
1138
+ raw_sources = projection.get("sources") or []
1139
+ if external_projection:
1140
+ if not isinstance(raw_sources, (list, tuple)):
1141
+ raise ValueError(
1142
+ "external concern sources must be a sequence"
1143
+ )
1144
+ for value in raw_sources:
1145
+ if (
1146
+ type(value) is not str
1147
+ or not value
1148
+ or value != value.strip()
1149
+ or len(value) > 200
1150
+ ):
1151
+ raise ValueError(
1152
+ "external concern source is not lossless"
1153
+ )
1154
+ sources = list(dict.fromkeys(raw_sources))[:30]
1155
+ else:
1156
+ sources = list(dict.fromkeys(
1157
+ str(value)[:200]
1158
+ for value in raw_sources
1159
+ if str(value or "").strip()
1160
+ ))[:30]
1161
+ max_thoughts = max(1, min(100, int(
1162
+ projection.get("max_thoughts") or _thought_budget())))
1163
+ subject = str(
1164
+ projection.get("subject_person_id") or ""
1165
+ )
1166
+ viewer_scope = str(
1167
+ projection.get("viewer_scope") or "owner"
1168
+ )
1169
+ if len(subject) > SUBJECT_PERSON_ID_MAX_CHARS:
1170
+ raise ValueError("concern subject exceeds safe bound")
1171
+ if (
1172
+ not viewer_scope
1173
+ or len(viewer_scope) > VIEWER_SCOPE_MAX_CHARS
1174
+ ):
1175
+ raise ValueError("concern viewer scope exceeds safe bound")
1176
+ shareability = str(
1177
+ projection.get("shareability") or "owner_private")
1178
+ if shareability not in {
1179
+ "owner_private", "subject_private", "shared", "public",
1180
+ }:
1181
+ raise ValueError("invalid concern shareability")
1182
+ producer = str(
1183
+ projection.get("producer_name") or "event_concerns"
1184
+ )[:64]
1185
+ producer_mode = str(
1186
+ projection.get("producer_mode") or "unknown"
1187
+ ).strip().lower()[:16]
1188
+ if producer_mode not in {"off", "shadow", "live", "unknown"}:
1189
+ producer_mode = "unknown"
1190
+ producer_revision = str(
1191
+ projection.get("producer_revision")
1192
+ or "event-concern-reducer-v2"
1193
+ )[:128]
1194
+
1195
+ if row is not None:
1196
+ concern_id = row["concern_id"]
1197
+ prior_digest = (row["last_material_digest"] or "")\
1198
+ if "last_material_digest" in row.keys() else ""
1199
+ if prior_digest == digest:
1200
+ disposition = "duplicate_material"
1201
+ else:
1202
+ merged = list(dict.fromkeys(
1203
+ json.loads(row["sources"] or "[]") + sources
1204
+ ))[:30]
1205
+ new_salience = min(
1206
+ 1.0, max(float(row["salience"]), salience) + 0.05,
1207
+ )
1208
+ self._conn.execute(
1209
+ """UPDATE concerns SET kind=?,summary=?,salience=?,
1210
+ sources=?,last_touched=?,subject_person_id=?,
1211
+ viewer_scope=?,shareability=?,
1212
+ last_material_event_id=?,last_material_event_seq=?,
1213
+ last_material_event_at=?,last_material_digest=?,
1214
+ producer_name=?,producer_mode=?,producer_revision=?,
1215
+ promoted_at=NULL,promotion_ref=NULL
1216
+ WHERE concern_id=?""",
1217
+ (kind, summary, new_salience, json.dumps(merged),
1218
+ time.time(), subject, viewer_scope, shareability,
1219
+ native_id, sequence, occurred_at, digest,
1220
+ producer, producer_mode, producer_revision,
1221
+ concern_id),
1222
+ )
1223
+ disposition = "updated"
1224
+ else:
1225
+ resolved = self._conn.execute(
1226
+ """SELECT * FROM concerns WHERE dedup_key=?
1227
+ AND status='resolved'
1228
+ ORDER BY last_touched DESC LIMIT 1""",
1229
+ (dedup_key,),
1230
+ ).fetchone()
1231
+ within_ttl = resolved is not None and (
1232
+ time.time() - float(resolved["last_touched"] or 0)
1233
+ ) < _resolved_ttl_hours() * 3600.0
1234
+ if within_ttl and not external_projection:
1235
+ concern_id = resolved["concern_id"]
1236
+ disposition = "suppressed_resolved"
1237
+ else:
1238
+ concern_id = f"c-{uuid.uuid4().hex[:12]}"
1239
+ touched = time.time()
1240
+ self._conn.execute(
1241
+ """INSERT INTO concerns
1242
+ (concern_id,kind,summary,salience,sources,
1243
+ dedup_key,max_thoughts,created_at,last_touched,
1244
+ subject_person_id,viewer_scope,shareability,
1245
+ last_material_event_id,last_material_event_seq,
1246
+ last_material_event_at,last_material_digest,
1247
+ producer_name,producer_mode,producer_revision)
1248
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1249
+ (concern_id, kind, summary, salience,
1250
+ json.dumps(sources), dedup_key, max_thoughts,
1251
+ touched, touched, subject, viewer_scope,
1252
+ shareability, native_id, sequence, occurred_at,
1253
+ digest, producer, producer_mode,
1254
+ producer_revision),
1255
+ )
1256
+ disposition = (
1257
+ "reopened"
1258
+ if external_projection and resolved is not None
1259
+ else "created"
1260
+ )
1261
+ elif apply_projection:
1262
+ raise ValueError("unsupported event projection operation")
1263
+
1264
+ if external_projection and advance_watermark:
1265
+ self._conn.execute(
1266
+ """INSERT INTO concern_external_event_watermarks
1267
+ (consumer_id,dedup_key,occurred_at,occurred_at_us,
1268
+ operation,material_digest,event_id,event_seq,updated_at)
1269
+ VALUES (?,?,?,?,?,?,?,?,?)
1270
+ ON CONFLICT(consumer_id,dedup_key) DO UPDATE SET
1271
+ occurred_at=excluded.occurred_at,
1272
+ occurred_at_us=excluded.occurred_at_us,
1273
+ operation=excluded.operation,
1274
+ material_digest=excluded.material_digest,
1275
+ event_id=excluded.event_id,
1276
+ event_seq=excluded.event_seq,
1277
+ updated_at=excluded.updated_at""",
1278
+ (
1279
+ consumer, dedup_key, occurred_at, occurred_at_us,
1280
+ operation, digest, native_id, sequence, now_iso,
1281
+ ),
1282
+ )
1283
+
1284
+ self._conn.execute(
1285
+ """INSERT INTO concern_event_receipts
1286
+ (consumer_id,event_seq,event_id,event_type,material_digest,
1287
+ disposition,concern_id,reason,processed_at)
1288
+ VALUES (?,?,?,?,?,?,?,?,?)""",
1289
+ (consumer, sequence, native_id, native_type, digest,
1290
+ disposition, concern_id, reason, now_iso),
1291
+ )
1292
+ self._conn.execute(
1293
+ """UPDATE concern_event_cursors
1294
+ SET last_seq=?,updated_at=?,last_error=NULL
1295
+ WHERE consumer_id=?""",
1296
+ (sequence, now_iso, consumer),
1297
+ )
1298
+ self._conn.commit()
1299
+ except Exception:
1300
+ self._conn.rollback()
1301
+ raise
1302
+
1303
+ return {
1304
+ "status": "processed",
1305
+ "disposition": disposition,
1306
+ "concern_id": concern_id,
1307
+ "cursor": sequence,
1308
+ }
1309
+
1310
+ def promote_concern(
1311
+ self,
1312
+ concern_id: str,
1313
+ *,
1314
+ expected_material_digest: str,
1315
+ promotion_ref: str,
1316
+ now: Optional[datetime] = None,
1317
+ ) -> Concern:
1318
+ """Explicitly promote one immutable shadow material version to live.
1319
+
1320
+ The source mode remains visible; promotion is a separate attestation.
1321
+ Exact replay is idempotent and a changed digest/ref is rejected.
1322
+ """
1323
+
1324
+ reference = str(promotion_ref or "").strip()[:256]
1325
+ expected = str(expected_material_digest or "").strip()[:128]
1326
+ if not reference or not expected:
1327
+ raise ValueError("promotion reference and material digest are required")
1328
+ observed = now or datetime.now(timezone.utc)
1329
+ if observed.tzinfo is None:
1330
+ observed = observed.replace(tzinfo=timezone.utc)
1331
+ promoted = observed.astimezone(timezone.utc).isoformat()
1332
+ with self._lock:
1333
+ row = self._conn.execute(
1334
+ "SELECT * FROM concerns WHERE concern_id=?", (concern_id,),
1335
+ ).fetchone()
1336
+ if row is None:
1337
+ raise ValueError("unknown concern")
1338
+ material = str(row["last_material_digest"] or "")
1339
+ if material != expected:
1340
+ raise ValueError("concern material changed before promotion")
1341
+ prior_ref = str(row["promotion_ref"] or "")
1342
+ if prior_ref:
1343
+ if prior_ref != reference:
1344
+ raise ValueError("concern already has a different promotion")
1345
+ return self._row(row)
1346
+ updated = self._conn.execute(
1347
+ "UPDATE concerns SET promoted_at=?,promotion_ref=? "
1348
+ "WHERE concern_id=? AND last_material_digest=? "
1349
+ "AND (promotion_ref IS NULL OR promotion_ref='')",
1350
+ (promoted, reference, concern_id, expected),
1351
+ )
1352
+ self._conn.commit()
1353
+ current = self._conn.execute(
1354
+ "SELECT * FROM concerns WHERE concern_id=?", (concern_id,),
1355
+ ).fetchone()
1356
+ if (
1357
+ updated.rowcount != 1
1358
+ or current is None
1359
+ or str(current["last_material_digest"] or "") != expected
1360
+ or str(current["promotion_ref"] or "") != reference
1361
+ ):
1362
+ raise ValueError("concern changed during promotion")
1363
+ return self._row(current)
1364
+
1365
+ def active(self, limit: int = 100) -> List[Concern]:
1366
+ with self._lock:
1367
+ rows = self._conn.execute(
1368
+ "SELECT * FROM concerns WHERE status='active'"
1369
+ " ORDER BY salience DESC LIMIT ?", (limit,)).fetchall()
1370
+ return [self._row(r) for r in rows]
1371
+
1372
+ @staticmethod
1373
+ def _producer_mode_exclusions(
1374
+ values: Iterable[tuple[str, str]],
1375
+ ) -> tuple[tuple[str, str], ...]:
1376
+ return tuple(dict.fromkeys(
1377
+ (
1378
+ str(producer or "legacy").strip()[:64],
1379
+ str(mode or "unknown").strip().lower()[:16],
1380
+ )
1381
+ for producer, mode in values
1382
+ if str(producer or "").strip() and str(mode or "").strip()
1383
+ ))
1384
+
1385
+ def active_for_maintenance(
1386
+ self,
1387
+ *,
1388
+ excluded_producer_modes: Iterable[tuple[str, str]] = (),
1389
+ limit: int = 500,
1390
+ ) -> List[Concern]:
1391
+ exclusions = self._producer_mode_exclusions(excluded_producer_modes)
1392
+ where = "status='active'"
1393
+ params: List[Any] = []
1394
+ if exclusions:
1395
+ terms = " OR ".join(
1396
+ "(COALESCE(producer_name,'legacy')=? AND "
1397
+ "COALESCE(producer_mode,'unknown')=?)"
1398
+ for _ in exclusions
1399
+ )
1400
+ where += f" AND NOT ({terms})"
1401
+ for producer, mode in exclusions:
1402
+ params.extend((producer, mode))
1403
+ params.append(max(1, min(int(limit), 500)))
1404
+ with self._lock:
1405
+ rows = self._conn.execute(
1406
+ f"SELECT * FROM concerns WHERE {where} "
1407
+ "ORDER BY salience DESC LIMIT ?",
1408
+ params,
1409
+ ).fetchall()
1410
+ return [self._row(row) for row in rows]
1411
+
1412
+ def active_without_producer(
1413
+ self, producer_name: str, *, limit: int = 100,
1414
+ ) -> List[Concern]:
1415
+ """Return active rows while excluding one producer at query time.
1416
+
1417
+ This avoids a fixed-window starvation bug without loading an
1418
+ unbounded number of held rows into memory. The helper is generic;
1419
+ schedulers retain ownership of which producer, if any, is ineligible.
1420
+ """
1421
+
1422
+ return self.active_without_producers((producer_name,), limit=limit)
1423
+
1424
+ def active_without_producers(
1425
+ self, producer_names: Iterable[str], *, limit: int = 100,
1426
+ ) -> List[Concern]:
1427
+ """Return active rows excluding a bounded set of held producers."""
1428
+
1429
+ values = (
1430
+ (producer_names,)
1431
+ if isinstance(producer_names, str)
1432
+ else producer_names
1433
+ )
1434
+ producers = tuple(dict.fromkeys(
1435
+ str(value or "").strip()[:64]
1436
+ for value in values
1437
+ if str(value or "").strip()
1438
+ ))
1439
+ if not producers:
1440
+ return self.active(limit=limit)
1441
+ bounded = max(1, min(int(limit), 500))
1442
+ marks = ",".join("?" for _ in producers)
1443
+ with self._lock:
1444
+ rows = self._conn.execute(
1445
+ "SELECT * FROM concerns WHERE status='active' "
1446
+ f"AND (producer_name IS NULL OR producer_name NOT IN ({marks})) "
1447
+ "ORDER BY salience DESC LIMIT ?",
1448
+ (*producers, bounded),
1449
+ ).fetchall()
1450
+ return [self._row(row) for row in rows]
1451
+
1452
+ def active_for_viewer(
1453
+ self,
1454
+ *,
1455
+ viewer_person_id: str,
1456
+ owner_person_id: str,
1457
+ audiences: set[str] | frozenset[str] = frozenset(),
1458
+ limit: int = 100,
1459
+ ) -> List[Concern]:
1460
+ # Fetch beyond the requested cap so private rows do not starve visible
1461
+ # rows below them. Workspace capacity is bounded, so this remains
1462
+ # cheap and avoids encoding policy into SQL fragments.
1463
+ rows = self.active(limit=max(_capacity(), limit, 200))
1464
+ return [
1465
+ concern for concern in rows
1466
+ if concern.visible_to(
1467
+ viewer_person_id=viewer_person_id,
1468
+ owner_person_id=owner_person_id,
1469
+ audiences=audiences,
1470
+ )
1471
+ ][:limit]
1472
+
1473
+ def get(self, concern_id: str) -> Optional[Concern]:
1474
+ with self._lock:
1475
+ r = self._conn.execute(
1476
+ "SELECT * FROM concerns WHERE concern_id=?",
1477
+ (concern_id,)).fetchone()
1478
+ return self._row(r) if r else None
1479
+
1480
+ def set_salience(self, concern_id: str, salience: float) -> None:
1481
+ with self._lock:
1482
+ self._conn.execute(
1483
+ "UPDATE concerns SET salience=?, last_touched=? "
1484
+ "WHERE concern_id=?",
1485
+ (max(0.0, min(1.0, salience)), time.time(), concern_id))
1486
+ self._conn.commit()
1487
+
1488
+ def record_thought(self, concern_id: str, note: str, *,
1489
+ resolved: bool, salience: float) -> None:
1490
+ now = time.time()
1491
+ with self._lock:
1492
+ self._conn.execute(
1493
+ "UPDATE concerns SET thoughts_spent=thoughts_spent+1,"
1494
+ " last_note=?, last_thought_at=?, last_touched=?,"
1495
+ " salience=?, status=? WHERE concern_id=?",
1496
+ (note[:500], now, now, max(0.0, min(1.0, salience)),
1497
+ "resolved" if resolved else "active", concern_id))
1498
+ self._conn.commit()
1499
+
1500
+ @staticmethod
1501
+ def _resolution_receipt(row: sqlite3.Row) -> Dict[str, Any]:
1502
+ stored_cascade = row["cascade"]
1503
+ if stored_cascade is None:
1504
+ cascade = None
1505
+ elif type(stored_cascade) is int and stored_cascade in (0, 1):
1506
+ cascade = bool(stored_cascade)
1507
+ else:
1508
+ raise ValueError("concern resolution receipt cascade is invalid")
1509
+ receipt = {
1510
+ "schema": "ColonyConcernResolutionReceiptV1",
1511
+ "version": 1,
1512
+ "resolution_id": row["resolution_id"],
1513
+ "record_digest": row["record_digest"],
1514
+ "concern_id": row["concern_id"],
1515
+ "outcome": row["outcome"],
1516
+ "note": row["note"],
1517
+ "note_digest": row["note_digest"],
1518
+ "cascade": cascade,
1519
+ "resolved_by": row["resolved_by"],
1520
+ "resolved_at": row["resolved_at"],
1521
+ "provenance": row["provenance"],
1522
+ }
1523
+ expected = _resolution_material(
1524
+ concern_id=receipt["concern_id"],
1525
+ outcome=receipt["outcome"],
1526
+ note=receipt["note"],
1527
+ cascade=receipt["cascade"],
1528
+ resolved_by=receipt["resolved_by"],
1529
+ resolved_at=receipt["resolved_at"],
1530
+ provenance=receipt["provenance"],
1531
+ )
1532
+ if any(
1533
+ receipt[name] != expected[name]
1534
+ for name in ("note_digest", "record_digest", "resolution_id")
1535
+ ):
1536
+ raise ValueError("concern resolution receipt integrity check failed")
1537
+ return receipt
1538
+
1539
+ @staticmethod
1540
+ def _cascade_intent_receipt(
1541
+ row: sqlite3.Row,
1542
+ base: Mapping[str, Any],
1543
+ ) -> Dict[str, Any]:
1544
+ try:
1545
+ payload = json.loads(row["intent_json"])
1546
+ if type(payload) is not dict:
1547
+ raise ValueError
1548
+ expected = _cascade_intent_material(
1549
+ resolution_id=payload["resolution_id"],
1550
+ concern_id=payload["concern_id"],
1551
+ requested=payload["requested"],
1552
+ source_refs=payload["source_refs"],
1553
+ captured_at=payload["captured_at"],
1554
+ capture_provenance=payload["capture_provenance"],
1555
+ )
1556
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
1557
+ raise ValueError("concern cascade intent integrity check failed") from exc
1558
+ if (
1559
+ set(payload) != set(expected)
1560
+ or payload != expected
1561
+ or row["intent_id"] != expected["intent_id"]
1562
+ or row["resolution_id"] != expected["resolution_id"]
1563
+ or row["concern_id"] != expected["concern_id"]
1564
+ or row["record_digest"] != expected["record_digest"]
1565
+ or payload["resolution_id"] != base["resolution_id"]
1566
+ or payload["concern_id"] != base["concern_id"]
1567
+ or payload["requested"] is not base["cascade"]
1568
+ ):
1569
+ raise ValueError("concern cascade intent integrity check failed")
1570
+ return payload
1571
+
1572
+ @staticmethod
1573
+ def _cascade_receipt(
1574
+ row: sqlite3.Row,
1575
+ base: Mapping[str, Any],
1576
+ intent: Mapping[str, Any],
1577
+ ) -> Dict[str, Any]:
1578
+ try:
1579
+ payload = json.loads(row["receipt_json"])
1580
+ if type(payload) is not dict:
1581
+ raise ValueError
1582
+ expected = _cascade_material(
1583
+ intent_id=payload["intent_id"],
1584
+ intent_digest=payload["intent_digest"],
1585
+ resolution_id=payload["resolution_id"],
1586
+ concern_id=payload["concern_id"],
1587
+ requested=payload["requested"],
1588
+ source_refs=payload["source_refs"],
1589
+ source_capture=payload["source_capture"],
1590
+ status=payload["status"],
1591
+ settled_sources=payload["settled_sources"],
1592
+ failed_sources=payload["failed_sources"],
1593
+ unexpected_result_count=payload["unexpected_result_count"],
1594
+ failure_codes=payload["failure_codes"],
1595
+ failure_digest=payload["failure_digest"],
1596
+ recorded_at=payload["recorded_at"],
1597
+ )
1598
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
1599
+ raise ValueError("concern cascade receipt integrity check failed") from exc
1600
+ linkage = {
1601
+ "intent_id": intent["intent_id"],
1602
+ "intent_digest": intent["record_digest"],
1603
+ "resolution_id": base["resolution_id"],
1604
+ "concern_id": base["concern_id"],
1605
+ "requested": intent["requested"],
1606
+ "source_refs": intent["source_refs"],
1607
+ "source_capture": intent["capture_provenance"],
1608
+ }
1609
+ if (
1610
+ set(payload) != set(expected)
1611
+ or payload != expected
1612
+ or any(payload[name] != value for name, value in linkage.items())
1613
+ or row["cascade_receipt_id"] != expected["cascade_receipt_id"]
1614
+ or row["intent_id"] != expected["intent_id"]
1615
+ or row["resolution_id"] != expected["resolution_id"]
1616
+ or row["concern_id"] != expected["concern_id"]
1617
+ or row["record_digest"] != expected["record_digest"]
1618
+ ):
1619
+ raise ValueError("concern cascade receipt integrity check failed")
1620
+ return payload
1621
+
1622
+ def _cascade_records(
1623
+ self,
1624
+ base: Mapping[str, Any],
1625
+ ) -> tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
1626
+ intent_row = self._conn.execute(
1627
+ """SELECT * FROM concern_resolution_cascade_intents
1628
+ WHERE resolution_id=?""",
1629
+ (base["resolution_id"],),
1630
+ ).fetchone()
1631
+ if intent_row is None:
1632
+ raise ValueError("concern cascade intent is missing")
1633
+ intent = self._cascade_intent_receipt(intent_row, base)
1634
+ terminal_row = self._conn.execute(
1635
+ """SELECT * FROM concern_resolution_cascade_receipts
1636
+ WHERE intent_id=?""",
1637
+ (intent["intent_id"],),
1638
+ ).fetchone()
1639
+ terminal = (
1640
+ self._cascade_receipt(terminal_row, base, intent)
1641
+ if terminal_row is not None else None
1642
+ )
1643
+ return intent, terminal
1644
+
1645
+ @staticmethod
1646
+ def _attach_cascade_evidence(
1647
+ base: Mapping[str, Any],
1648
+ intent: Mapping[str, Any],
1649
+ terminal: Optional[Mapping[str, Any]],
1650
+ ) -> Dict[str, Any]:
1651
+ receipt = dict(base)
1652
+ if terminal is not None:
1653
+ receipt["cascade_evidence"] = dict(terminal)
1654
+ return receipt
1655
+ if not (
1656
+ base["provenance"] == _OWNER_RESOLUTION_PROVENANCE
1657
+ and base["cascade"] is True
1658
+ and intent["requested"] is True
1659
+ and intent["source_refs"]
1660
+ and intent["capture_provenance"] == "resolution_transaction"
1661
+ ):
1662
+ raise ValueError("concern cascade terminal receipt is missing")
1663
+ receipt["cascade_evidence"] = {
1664
+ "schema": "ColonyConcernCascadeStateV1",
1665
+ "version": 1,
1666
+ "intent_id": intent["intent_id"],
1667
+ "intent_digest": intent["record_digest"],
1668
+ "resolution_id": base["resolution_id"],
1669
+ "concern_id": base["concern_id"],
1670
+ "requested": True,
1671
+ "source_refs": list(intent["source_refs"]),
1672
+ "source_capture": intent["capture_provenance"],
1673
+ "status": "pending",
1674
+ "started_at": intent["captured_at"],
1675
+ }
1676
+ return receipt
1677
+
1678
+ def get_resolution(self, concern_id: str) -> Optional[Dict[str, Any]]:
1679
+ """Return the immutable decision and its validated cascade projection."""
1680
+
1681
+ with self._lock:
1682
+ row = self._conn.execute(
1683
+ "SELECT * FROM concern_resolutions WHERE concern_id=?",
1684
+ (concern_id,),
1685
+ ).fetchone()
1686
+ if row is None:
1687
+ return None
1688
+ base = self._resolution_receipt(row)
1689
+ intent, terminal = self._cascade_records(base)
1690
+ return self._attach_cascade_evidence(base, intent, terminal)
1691
+
1692
+ def recent_resolutions(
1693
+ self, limit: int = RECENT_RESOLUTIONS_LIMIT,
1694
+ ) -> List[Dict[str, Any]]:
1695
+ """Return a bounded, integrity-checked newest-first receipt list."""
1696
+
1697
+ bounded = max(0, min(int(limit), RECENT_RESOLUTIONS_LIMIT))
1698
+ if bounded == 0:
1699
+ return []
1700
+ with self._lock:
1701
+ rows = self._conn.execute(
1702
+ """SELECT * FROM concern_resolutions
1703
+ ORDER BY resolved_at DESC,resolution_id DESC LIMIT ?""",
1704
+ (bounded,),
1705
+ ).fetchall()
1706
+ receipts = []
1707
+ for row in rows:
1708
+ base = self._resolution_receipt(row)
1709
+ intent, terminal = self._cascade_records(base)
1710
+ receipts.append(
1711
+ self._attach_cascade_evidence(base, intent, terminal)
1712
+ )
1713
+ return receipts
1714
+
1715
+ def resolve_with_owner_record(
1716
+ self,
1717
+ concern_id: str,
1718
+ *,
1719
+ outcome: str,
1720
+ note: str,
1721
+ cascade: bool,
1722
+ resolved_by: str,
1723
+ ) -> tuple[Dict[str, Any], bool]:
1724
+ """Resolve once and bind the exact owner decision transactionally.
1725
+
1726
+ The returned boolean is true only for the first write. A byte-exact
1727
+ semantic replay returns the original receipt; a different outcome,
1728
+ note, cascade choice, or resolver conflicts. A legacy receipt also
1729
+ conflicts because its missing facts cannot truthfully be filled in
1730
+ after the event.
1731
+ """
1732
+
1733
+ if type(cascade) is not bool:
1734
+ raise ValueError("concern resolution cascade must be boolean")
1735
+ expected = {
1736
+ "concern_id": str(concern_id),
1737
+ "outcome": str(outcome),
1738
+ "note": str(note),
1739
+ "cascade": cascade,
1740
+ "resolved_by": str(resolved_by),
1741
+ "provenance": _OWNER_RESOLUTION_PROVENANCE,
1742
+ }
1743
+ with self._lock:
1744
+ self._conn.execute("BEGIN IMMEDIATE")
1745
+ try:
1746
+ existing = self._conn.execute(
1747
+ "SELECT * FROM concern_resolutions WHERE concern_id=?",
1748
+ (concern_id,),
1749
+ ).fetchone()
1750
+ if existing is not None:
1751
+ base = self._resolution_receipt(existing)
1752
+ intent, terminal = self._cascade_records(base)
1753
+ receipt = self._attach_cascade_evidence(base, intent, terminal)
1754
+ if any(base[name] != value for name, value in expected.items()):
1755
+ raise ConcernResolutionConflict(
1756
+ "immutable concern resolution replay mismatch"
1757
+ )
1758
+ self._conn.commit()
1759
+ return receipt, False
1760
+
1761
+ concern = self._conn.execute(
1762
+ "SELECT status,sources FROM concerns WHERE concern_id=?",
1763
+ (concern_id,),
1764
+ ).fetchone()
1765
+ if concern is None:
1766
+ raise ValueError("unknown concern")
1767
+ if concern["status"] != "active":
1768
+ raise ConcernResolutionConflict(
1769
+ "concern is terminal without an exact owner resolution receipt"
1770
+ )
1771
+
1772
+ source_refs = self._stored_source_refs(concern["sources"])
1773
+ resolved_at = datetime.now(timezone.utc).isoformat()
1774
+ material = _resolution_material(
1775
+ concern_id=expected["concern_id"],
1776
+ outcome=expected["outcome"],
1777
+ note=expected["note"],
1778
+ cascade=expected["cascade"],
1779
+ resolved_by=expected["resolved_by"],
1780
+ resolved_at=resolved_at,
1781
+ provenance=expected["provenance"],
1782
+ )
1783
+ self._conn.execute(
1784
+ """INSERT INTO concern_resolutions
1785
+ (resolution_id,concern_id,outcome,note,note_digest,
1786
+ cascade,resolved_by,resolved_at,provenance,record_digest)
1787
+ VALUES (?,?,?,?,?,?,?,?,?,?)""",
1788
+ (
1789
+ material["resolution_id"], material["concern_id"],
1790
+ material["outcome"], material["note"],
1791
+ material["note_digest"], int(material["cascade"]),
1792
+ material["resolved_by"], material["resolved_at"],
1793
+ material["provenance"], material["record_digest"],
1794
+ ),
1795
+ )
1796
+ intent = _cascade_intent_material(
1797
+ resolution_id=material["resolution_id"],
1798
+ concern_id=material["concern_id"],
1799
+ requested=material["cascade"],
1800
+ source_refs=source_refs,
1801
+ captured_at=resolved_at,
1802
+ capture_provenance="resolution_transaction",
1803
+ )
1804
+ self._insert_cascade_intent(intent)
1805
+ terminal: Optional[Dict[str, Any]] = None
1806
+ terminal_status = (
1807
+ "not_requested" if material["cascade"] is False
1808
+ else "not_applicable" if not source_refs
1809
+ else None
1810
+ )
1811
+ if terminal_status is not None:
1812
+ terminal = _cascade_material(
1813
+ intent_id=intent["intent_id"],
1814
+ intent_digest=intent["record_digest"],
1815
+ resolution_id=material["resolution_id"],
1816
+ concern_id=material["concern_id"],
1817
+ requested=material["cascade"],
1818
+ source_refs=source_refs,
1819
+ source_capture=intent["capture_provenance"],
1820
+ status=terminal_status,
1821
+ settled_sources=[],
1822
+ failed_sources=[],
1823
+ unexpected_result_count=0,
1824
+ failure_codes=[],
1825
+ failure_digest=None,
1826
+ recorded_at=resolved_at,
1827
+ )
1828
+ self._insert_cascade_receipt(terminal)
1829
+ now = time.time()
1830
+ changed = self._conn.execute(
1831
+ """UPDATE concerns SET thoughts_spent=thoughts_spent+1,
1832
+ last_note=?,last_thought_at=?,last_touched=?,salience=0.0,
1833
+ status='resolved' WHERE concern_id=? AND status='active'""",
1834
+ (expected["note"][:500], now, now, concern_id),
1835
+ ).rowcount
1836
+ if changed != 1:
1837
+ raise ConcernResolutionConflict(
1838
+ "concern changed before its owner resolution was recorded"
1839
+ )
1840
+ self._conn.commit()
1841
+ return self._attach_cascade_evidence(
1842
+ material, intent, terminal,
1843
+ ), True
1844
+ except Exception:
1845
+ self._conn.rollback()
1846
+ raise
1847
+
1848
+ @staticmethod
1849
+ def _cascade_execution_summary(
1850
+ intent: Mapping[str, Any],
1851
+ results: Any,
1852
+ error: Optional[BaseException],
1853
+ ) -> Dict[str, Any]:
1854
+ refs = list(intent["source_refs"])
1855
+ accepted: Dict[str, Mapping[str, Any]] = {}
1856
+ diagnostics: List[Dict[str, Any]] = []
1857
+ codes: set[str] = set()
1858
+ unexpected = 0
1859
+ if not isinstance(results, (list, tuple)):
1860
+ results = []
1861
+ unexpected += 1
1862
+ codes.add("malformed_result")
1863
+ elif len(results) > 60:
1864
+ unexpected += len(results) - 60
1865
+ codes.add("malformed_result")
1866
+ results = results[:60]
1867
+ for item in results:
1868
+ if not isinstance(item, Mapping):
1869
+ unexpected += 1
1870
+ codes.add("malformed_result")
1871
+ continue
1872
+ source = item.get("source")
1873
+ if type(source) is not str:
1874
+ unexpected += 1
1875
+ codes.add("malformed_result")
1876
+ continue
1877
+ try:
1878
+ normalized = _cascade_source_refs([source])[0]
1879
+ except (IndexError, ValueError):
1880
+ unexpected += 1
1881
+ codes.add("malformed_result")
1882
+ continue
1883
+ if normalized not in refs:
1884
+ unexpected += 1
1885
+ codes.add("unknown_result")
1886
+ continue
1887
+ if normalized in accepted:
1888
+ unexpected += 1
1889
+ codes.add("duplicate_result")
1890
+ continue
1891
+ accepted[normalized] = item
1892
+
1893
+ settled: List[str] = []
1894
+ failed: List[str] = []
1895
+ for ref in refs:
1896
+ item = accepted.get(ref)
1897
+ if item is None:
1898
+ failed.append(ref)
1899
+ codes.add("missing_result")
1900
+ diagnostics.append({"source": ref, "code": "missing_result"})
1901
+ continue
1902
+ raw_error = item.get("error")
1903
+ if raw_error:
1904
+ failed.append(ref)
1905
+ error_code = (
1906
+ raw_error
1907
+ if raw_error in {
1908
+ "operation_conflict", "operation_unverified",
1909
+ "settler_error",
1910
+ }
1911
+ else "settler_error"
1912
+ )
1913
+ codes.add(error_code)
1914
+ supplied_digest = item.get("error_digest")
1915
+ error_digest = (
1916
+ supplied_digest
1917
+ if type(supplied_digest) is str
1918
+ and re.fullmatch(r"[a-f0-9]{64}", supplied_digest)
1919
+ else hashlib.sha256(
1920
+ str(raw_error).encode("utf-8", errors="replace")
1921
+ ).hexdigest()
1922
+ )
1923
+ diagnostics.append({
1924
+ "source": ref,
1925
+ "code": error_code,
1926
+ "error_digest": error_digest,
1927
+ })
1928
+ elif item.get("settled") is not True:
1929
+ failed.append(ref)
1930
+ codes.add("not_settled")
1931
+ diagnostics.append({"source": ref, "code": "not_settled"})
1932
+ else:
1933
+ settled.append(ref)
1934
+ diagnostics.append({"source": ref, "code": "settled"})
1935
+
1936
+ if error is not None:
1937
+ codes.add("execution_error")
1938
+ diagnostics.append({
1939
+ "code": "execution_error",
1940
+ "error_digest": hashlib.sha256(
1941
+ str(error).encode("utf-8", errors="replace")
1942
+ ).hexdigest(),
1943
+ })
1944
+ failed_status = bool(failed or unexpected or error is not None)
1945
+ failure_digest = None
1946
+ if failed_status:
1947
+ failure_digest = hashlib.sha256(_canonical_json({
1948
+ "intent_id": intent["intent_id"],
1949
+ "failure_codes": sorted(codes),
1950
+ "unexpected_result_count": unexpected,
1951
+ "diagnostics": diagnostics,
1952
+ }).encode("utf-8")).hexdigest()
1953
+ return {
1954
+ "status": "failed" if failed_status else "succeeded",
1955
+ "settled_sources": settled,
1956
+ "failed_sources": failed,
1957
+ "unexpected_result_count": unexpected,
1958
+ "failure_codes": sorted(codes),
1959
+ "failure_digest": failure_digest,
1960
+ }
1961
+
1962
+ def finalize_owner_cascade(
1963
+ self,
1964
+ concern_id: str,
1965
+ *,
1966
+ results: Any,
1967
+ error: Optional[BaseException] = None,
1968
+ ) -> Dict[str, Any]:
1969
+ """Append one terminal outcome for a transaction-bound cascade intent."""
1970
+
1971
+ with self._lock:
1972
+ self._conn.execute("BEGIN IMMEDIATE")
1973
+ try:
1974
+ row = self._conn.execute(
1975
+ "SELECT * FROM concern_resolutions WHERE concern_id=?",
1976
+ (concern_id,),
1977
+ ).fetchone()
1978
+ if row is None:
1979
+ raise ValueError("unknown concern resolution")
1980
+ base = self._resolution_receipt(row)
1981
+ intent, existing = self._cascade_records(base)
1982
+ if not (
1983
+ base["provenance"] == _OWNER_RESOLUTION_PROVENANCE
1984
+ and intent["capture_provenance"] == "resolution_transaction"
1985
+ and intent["requested"] is True
1986
+ and intent["source_refs"]
1987
+ ):
1988
+ raise ConcernResolutionConflict(
1989
+ "concern does not have a pending cascade intent"
1990
+ )
1991
+ summary = self._cascade_execution_summary(intent, results, error)
1992
+ recorded_at = (
1993
+ existing["recorded_at"] if existing is not None
1994
+ else datetime.now(timezone.utc).isoformat()
1995
+ )
1996
+ terminal = _cascade_material(
1997
+ intent_id=intent["intent_id"],
1998
+ intent_digest=intent["record_digest"],
1999
+ resolution_id=base["resolution_id"],
2000
+ concern_id=base["concern_id"],
2001
+ requested=intent["requested"],
2002
+ source_refs=intent["source_refs"],
2003
+ source_capture=intent["capture_provenance"],
2004
+ recorded_at=recorded_at,
2005
+ **summary,
2006
+ )
2007
+ if existing is not None:
2008
+ if existing != terminal:
2009
+ raise ConcernResolutionConflict(
2010
+ "immutable cascade outcome replay mismatch"
2011
+ )
2012
+ else:
2013
+ self._insert_cascade_receipt(terminal)
2014
+ self._conn.commit()
2015
+ return self._attach_cascade_evidence(base, intent, terminal)
2016
+ except Exception:
2017
+ self._conn.rollback()
2018
+ raise
2019
+
2020
+ def settle_with_evidence(
2021
+ self,
2022
+ concern_id: str,
2023
+ *,
2024
+ settlement_kind: str,
2025
+ settlement_ref: str,
2026
+ evidence_refs: List[str],
2027
+ reason: str,
2028
+ ) -> Dict[str, Any]:
2029
+ """Atomically settle one concern from server-validated evidence.
2030
+
2031
+ This does not mutate the upstream commitment/service/relationship
2032
+ source. The P3 cognition spine is the intended caller; model output
2033
+ alone has no access to this method.
2034
+ """
2035
+
2036
+ kind = str(settlement_kind or "").strip()
2037
+ if kind not in {"no_action", "project_outcome"}:
2038
+ raise ValueError("unsupported concern settlement kind")
2039
+ ref = str(settlement_ref or "").strip()[:256]
2040
+ refs = list(dict.fromkeys(
2041
+ str(value or "").strip()[:256]
2042
+ for value in evidence_refs or []
2043
+ if str(value or "").strip()
2044
+ ))[:60]
2045
+ if not ref or not refs or not str(reason or "").strip():
2046
+ raise ValueError("settlement reference, evidence, and reason are required")
2047
+ now_iso = datetime.now(timezone.utc).isoformat()
2048
+ with self._lock:
2049
+ existing = self._conn.execute(
2050
+ "SELECT * FROM concern_settlements WHERE concern_id=?",
2051
+ (concern_id,),
2052
+ ).fetchone()
2053
+ if existing is not None:
2054
+ row = dict(existing)
2055
+ if (
2056
+ row["settlement_ref"] != ref
2057
+ or row["settlement_kind"] != kind
2058
+ or json.loads(row["evidence_refs"] or "[]") != refs
2059
+ or row["reason"] != str(reason)[:800]
2060
+ ):
2061
+ raise ValueError("immutable concern settlement replay mismatch")
2062
+ row["evidence_refs"] = refs
2063
+ return row
2064
+ concern = self._conn.execute(
2065
+ "SELECT status FROM concerns WHERE concern_id=?",
2066
+ (concern_id,),
2067
+ ).fetchone()
2068
+ if concern is None:
2069
+ raise ValueError("unknown concern")
2070
+ if concern["status"] != "active":
2071
+ raise ValueError("concern is already terminal without this settlement")
2072
+ self._conn.execute("BEGIN IMMEDIATE")
2073
+ try:
2074
+ self._conn.execute(
2075
+ """INSERT INTO concern_settlements
2076
+ (settlement_ref,concern_id,settlement_kind,evidence_refs,
2077
+ reason,settled_at) VALUES (?,?,?,?,?,?)""",
2078
+ (ref, concern_id, kind, json.dumps(refs),
2079
+ str(reason)[:800], now_iso),
2080
+ )
2081
+ self._conn.execute(
2082
+ """UPDATE concerns SET status='resolved',salience=0.0,
2083
+ last_note=?,last_touched=? WHERE concern_id=?""",
2084
+ (str(reason)[:500], time.time(), concern_id),
2085
+ )
2086
+ self._conn.commit()
2087
+ except Exception:
2088
+ self._conn.rollback()
2089
+ raise
2090
+ return self.get_settlement(concern_id) or {}
2091
+
2092
+ def get_settlement(self, concern_id: str) -> Optional[Dict[str, Any]]:
2093
+ with self._lock:
2094
+ row = self._conn.execute(
2095
+ "SELECT * FROM concern_settlements WHERE concern_id=?",
2096
+ (concern_id,),
2097
+ ).fetchone()
2098
+ if row is None:
2099
+ return None
2100
+ result = dict(row)
2101
+ result["evidence_refs"] = json.loads(result["evidence_refs"] or "[]")
2102
+ return result
2103
+
2104
+ def set_memory_refs(self, concern_id: str, ids: List[str], cap: int = 12) -> None:
2105
+ """Record which memories the thinker consulted about this concern, most
2106
+ recent first, deduped and capped. A real, measured provenance link."""
2107
+ clean = [str(i) for i in (ids or []) if i]
2108
+ if not clean:
2109
+ return
2110
+ with self._lock:
2111
+ r = self._conn.execute(
2112
+ "SELECT memory_refs FROM concerns WHERE concern_id=?",
2113
+ (concern_id,)).fetchone()
2114
+ if r is None:
2115
+ return
2116
+ prior = []
2117
+ try:
2118
+ prior = json.loads((r["memory_refs"] if "memory_refs" in r.keys() else None) or "[]")
2119
+ except Exception:
2120
+ prior = []
2121
+ merged = list(dict.fromkeys(clean + prior))[:cap]
2122
+ self._conn.execute(
2123
+ "UPDATE concerns SET memory_refs=? WHERE concern_id=?",
2124
+ (json.dumps(merged), concern_id))
2125
+ self._conn.commit()
2126
+
2127
+ def resolve_by_dedup(self, dedup_key: str, note: str) -> int:
2128
+ """Resolve every ACTIVE concern carrying this dedup_key (reverse
2129
+ cascade: when the source itself gets settled directly, the concern
2130
+ raised from it must leave her mind too). Returns count resolved."""
2131
+ now = time.time()
2132
+ with self._lock:
2133
+ rows = self._conn.execute(
2134
+ "SELECT concern_id FROM concerns WHERE dedup_key=? "
2135
+ "AND status='active'", (dedup_key,)).fetchall()
2136
+ for r in rows:
2137
+ self._conn.execute(
2138
+ "UPDATE concerns SET status='resolved', salience=0.0,"
2139
+ " last_note=?, last_touched=? WHERE concern_id=?",
2140
+ (note[:500], now, r["concern_id"]))
2141
+ self._conn.commit()
2142
+ return len(rows)
2143
+
2144
+ def evict_below(
2145
+ self,
2146
+ floor: float,
2147
+ keep: int,
2148
+ *,
2149
+ excluded_producer_modes: Iterable[tuple[str, str]] = (),
2150
+ ) -> int:
2151
+ """Evict active concerns under the floor, and any beyond the capacity
2152
+ cap (lowest salience first). Returns count evicted."""
2153
+ with self._lock:
2154
+ exclusions = self._producer_mode_exclusions(
2155
+ excluded_producer_modes,
2156
+ )
2157
+ where = "status='active'"
2158
+ params: List[Any] = []
2159
+ if exclusions:
2160
+ terms = " OR ".join(
2161
+ "(COALESCE(producer_name,'legacy')=? AND "
2162
+ "COALESCE(producer_mode,'unknown')=?)"
2163
+ for _ in exclusions
2164
+ )
2165
+ where += f" AND NOT ({terms})"
2166
+ for producer, mode in exclusions:
2167
+ params.extend((producer, mode))
2168
+ rows = self._conn.execute(
2169
+ "SELECT concern_id, salience FROM concerns "
2170
+ f"WHERE {where} ORDER BY salience DESC",
2171
+ params,
2172
+ ).fetchall()
2173
+ to_evict = [r["concern_id"] for r in rows[keep:]]
2174
+ to_evict += [r["concern_id"] for r in rows[:keep]
2175
+ if r["salience"] < floor]
2176
+ for cid in set(to_evict):
2177
+ self._conn.execute(
2178
+ "UPDATE concerns SET status='evicted' WHERE concern_id=?",
2179
+ (cid,))
2180
+ self._conn.commit()
2181
+ return len(set(to_evict))
2182
+
2183
+
2184
+ class WorkspaceEngine:
2185
+ """Salience dynamics + the thinking scheduler.
2186
+
2187
+ The thinker is an injected async callable `thinker(concern) -> dict` with
2188
+ keys: progress(bool), resolve(bool), note(str), action(optional dict
2189
+ {"kind": "initiative"|"experiment"|"memory"|"none", ...}). Kept out of
2190
+ this module so ColonyAI stays model-agnostic and tests inject a fake.
2191
+ """
2192
+
2193
+ def __init__(self, store: ConcernStore, *,
2194
+ thinker: Optional[Callable[[Concern], Awaitable[Dict[str, Any]]]] = None,
2195
+ journal: Any = None,
2196
+ on_action: Optional[Callable[[Concern, Dict[str, Any]], Awaitable[None]]] = None,
2197
+ event_reducer: Any = None,
2198
+ external_event_reducer: Any = None,
2199
+ turn_event_reducer: Any = None,
2200
+ cognition_spine: Any = None) -> None:
2201
+ self.store = store
2202
+ self._thinker = thinker
2203
+ self._journal = journal
2204
+ self._on_action = on_action
2205
+ self.event_reducer = event_reducer
2206
+ self.external_event_reducer = external_event_reducer
2207
+ self.turn_event_reducer = turn_event_reducer
2208
+ self.cognition_spine = cognition_spine
2209
+
2210
+ # -- salience ---------------------------------------------------------
2211
+ def bump(self, *, kind: str, summary: str, dedup_key: str,
2212
+ salience: float = 0.5, sources: Optional[List[str]] = None,
2213
+ max_thoughts: Optional[int] = None,
2214
+ producer_name: str = "workspace",
2215
+ producer_mode: Optional[str] = None,
2216
+ producer_revision: str = "workspace-engine-v2") -> Concern:
2217
+ kind = kind if kind in CONCERN_KINDS else "thread"
2218
+ return self.store.upsert(
2219
+ kind=kind, summary=summary[:300], salience=salience,
2220
+ dedup_key=dedup_key[:200], sources=sources or [],
2221
+ max_thoughts=max_thoughts or _thought_budget(),
2222
+ producer_name=producer_name,
2223
+ producer_mode=producer_mode or workspace_mode(),
2224
+ producer_revision=producer_revision)
2225
+
2226
+ def decay(self) -> int:
2227
+ """Exponential time-decay of every active concern; evict the floor
2228
+ and anything over capacity. Returns the number evicted."""
2229
+ hl = _decay_half_life_hours() * 3600.0
2230
+ now = time.time()
2231
+ shadow_turns = (("turn_concerns", "shadow"),)
2232
+ for c in self.store.active_for_maintenance(
2233
+ excluded_producer_modes=shadow_turns,
2234
+ limit=500,
2235
+ ):
2236
+ dt = max(0.0, now - c.last_touched)
2237
+ factor = 0.5 ** (dt / hl) if hl > 0 else 1.0
2238
+ self.store.set_salience(c.concern_id, c.salience * factor)
2239
+ return self.store.evict_below(
2240
+ _evict_floor(), _capacity(),
2241
+ excluded_producer_modes=shadow_turns,
2242
+ )
2243
+
2244
+ def top(self) -> Optional[Concern]:
2245
+ active = self.store.active(limit=1)
2246
+ return active[0] if active else None
2247
+
2248
+ # -- thinking ---------------------------------------------------------
2249
+ async def think_once(self) -> Optional[Dict[str, Any]]:
2250
+ """Pop the most salient thinkable concern and run one thought.
2251
+ Returns the outcome dict, or None if nothing to think about."""
2252
+ # Once P3 is live, every autonomous thought must be a durable queue
2253
+ # job. Refuse an accidental fallback to the legacy direct LLM path.
2254
+ try:
2255
+ from apsimo.cognition.goal_spine import cognition_spine_exclusive
2256
+ if cognition_spine_exclusive():
2257
+ return None
2258
+ except Exception:
2259
+ pass
2260
+ if self._thinker is None:
2261
+ return None
2262
+ concern = None
2263
+ excluded_producers = ("external_event_concerns", "turn_concerns")
2264
+ excluding = getattr(self.store, "active_without_producers", None)
2265
+ if callable(excluding):
2266
+ candidates = excluding(excluded_producers, limit=20)
2267
+ else:
2268
+ candidates = [
2269
+ item for item in self.store.active(limit=200)
2270
+ if str(getattr(item, "producer_name", "") or "")
2271
+ not in excluded_producers
2272
+ ][:20]
2273
+ for c in candidates:
2274
+ # External reports and conversational evidence may only enter
2275
+ # cognition through the durable, read-only ThoughtJob/current-mode
2276
+ # admission path. The legacy direct thinker and its on_action
2277
+ # callback are never an allowed fallback, even when P3 is disabled
2278
+ # or temporarily unattached.
2279
+ if c.thoughts_spent < c.max_thoughts:
2280
+ concern = c
2281
+ break
2282
+ if concern is None:
2283
+ return None
2284
+ try:
2285
+ outcome = await self._thinker(concern) or {}
2286
+ except Exception as exc:
2287
+ logger.warning("workspace thinker failed: %s", exc)
2288
+ return None
2289
+ progressed = bool(outcome.get("progress"))
2290
+ resolved = bool(outcome.get("resolve"))
2291
+ note = str(outcome.get("note", ""))[:500]
2292
+ # progress sustains salience; no progress decays it harder so
2293
+ # rumination on a stuck concern fades instead of looping forever.
2294
+ new_sal = concern.salience * (0.9 if progressed else 0.6)
2295
+ self.store.record_thought(concern.concern_id, note,
2296
+ resolved=resolved, salience=new_sal)
2297
+ # persist the memory ids the thinker actually recalled about this
2298
+ # concern (provenance for the memory-field beams; render draws a beam
2299
+ # only when a ref id is also a point on the sampled field).
2300
+ refs = outcome.get("memory_refs")
2301
+ if refs:
2302
+ try:
2303
+ self.store.set_memory_refs(concern.concern_id, refs)
2304
+ except Exception:
2305
+ logger.debug("set_memory_refs failed", exc_info=True)
2306
+ self._log(f"thought on {concern.kind}: {concern.summary[:60]} "
2307
+ f"-> {'resolved' if resolved else 'progress' if progressed else 'no progress'}",
2308
+ note)
2309
+ action = outcome.get("action")
2310
+ if action and self._on_action is not None and workspace_mode() == "live":
2311
+ try:
2312
+ await self._on_action(concern, action)
2313
+ except Exception:
2314
+ logger.debug("workspace on_action failed", exc_info=True)
2315
+ return {"concern_id": concern.concern_id, "resolved": resolved,
2316
+ "progress": progressed, "note": note, "action": action}
2317
+
2318
+ def _log(self, desc: str, note: str) -> None:
2319
+ if self._journal is None:
2320
+ return
2321
+ try:
2322
+ self._journal.record("workspace", desc, reasoning=note,
2323
+ decision="noted", outcome="thought")
2324
+ except Exception:
2325
+ logger.debug("workspace journal write failed", exc_info=True)
2326
+
2327
+ # -- read side --------------------------------------------------------
2328
+ def snapshot(
2329
+ self,
2330
+ limit: int = 24,
2331
+ *,
2332
+ viewer_person_id: str = "",
2333
+ owner_person_id: str = "",
2334
+ audiences: set[str] | frozenset[str] = frozenset(),
2335
+ unrestricted: bool = True,
2336
+ ) -> Dict[str, Any]:
2337
+ active = (
2338
+ self.store.active(limit=limit)
2339
+ if unrestricted
2340
+ else self.store.active_for_viewer(
2341
+ viewer_person_id=viewer_person_id,
2342
+ owner_person_id=owner_person_id,
2343
+ audiences=audiences,
2344
+ limit=limit,
2345
+ )
2346
+ )
2347
+ result = {"mode": workspace_mode(),
2348
+ "capacity": _capacity(),
2349
+ "sleeping": in_sleep_window(),
2350
+ "concerns": [c.public() for c in active]}
2351
+ owner_receipts_visible = unrestricted or bool(
2352
+ viewer_person_id
2353
+ and owner_person_id
2354
+ and viewer_person_id == owner_person_id
2355
+ and "owner" in audiences
2356
+ )
2357
+ if owner_receipts_visible:
2358
+ result["recent_resolutions"] = self.store.recent_resolutions()
2359
+ if self.event_reducer is not None:
2360
+ try:
2361
+ result["event_reducer"] = self.event_reducer.status()
2362
+ except Exception:
2363
+ result["event_reducer"] = {
2364
+ "enabled": True,
2365
+ "healthy": False,
2366
+ "error": "status_unavailable",
2367
+ }
2368
+ if self.external_event_reducer is not None:
2369
+ try:
2370
+ result["external_event_reducer"] = (
2371
+ self.external_event_reducer.status()
2372
+ )
2373
+ except Exception:
2374
+ result["external_event_reducer"] = {
2375
+ "enabled": True,
2376
+ "healthy": False,
2377
+ "error": "status_unavailable",
2378
+ }
2379
+ if self.turn_event_reducer is not None:
2380
+ try:
2381
+ result["turn_event_reducer"] = self.turn_event_reducer.status()
2382
+ except Exception:
2383
+ result["turn_event_reducer"] = {
2384
+ "enabled": True,
2385
+ "healthy": False,
2386
+ "error": "status_unavailable",
2387
+ }
2388
+ return result