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,1623 @@
1
+ """Colony Contacts — SQLite-backed ContactStore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import logging
8
+ import re
9
+ import time
10
+ from abc import ABC, abstractmethod
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import aiosqlite
15
+
16
+ from .config import ContactsConfig
17
+ from .models import (
18
+ Contact,
19
+ ContactHandle,
20
+ ScopeMember,
21
+ TrustScope,
22
+ TRUST_TIERS,
23
+ TIER_DEFAULT_INTERACTION,
24
+ more_permissive_tier,
25
+ )
26
+
27
+ logger = logging.getLogger("colony.contacts.store")
28
+
29
+ _SCHEMA_FILE = Path(__file__).parent / "migrations" / "001_contacts_schema.sql"
30
+ _SCHEMA_FILE_002 = Path(__file__).parent / "migrations" / "002_trust_scopes.sql"
31
+ _MIGRATIONS_DIR = Path(__file__).parent / "migrations"
32
+
33
+ _PHONE_DIGITS = re.compile(r'\D')
34
+
35
+
36
+ def _gen_id(prefix: str) -> str:
37
+ import secrets
38
+ ts = int(time.time() * 1000)
39
+ rand = secrets.token_hex(6) # 12 hex chars, 48 bits of CSPRNG entropy
40
+ return f"{prefix}-{ts}-{rand}"
41
+
42
+
43
+ def _now_iso() -> str:
44
+ from datetime import datetime, timezone
45
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
46
+
47
+
48
+ def _normalize_phone(phone: str) -> str:
49
+ """Strip non-digit characters; preserve leading +."""
50
+ phone = phone.strip()
51
+ if phone.startswith("+"):
52
+ return "+" + _PHONE_DIGITS.sub("", phone[1:])
53
+ return _PHONE_DIGITS.sub("", phone)
54
+
55
+
56
+ def _normalize_email(email: str) -> str:
57
+ return email.strip().lower()
58
+
59
+
60
+ # A phone number is ONE identity across channels. Phone-bearing gateways resolve a number to the
61
+ # same contact regardless of which transport it arrived on (sms/rcs/imessage/signal/whatsapp).
62
+ from apsimo.channels.phone_gateways import get_phone_gateways as _get_phone_gateways
63
+
64
+
65
+ def _looks_like_phone(address: str) -> bool:
66
+ """True if `address` is a phone number (digits/+, no letters) — used to route an unknown-gateway
67
+ sender (e.g. a 'custom' platform) into the phone-identity resolution path."""
68
+ s = (address or "").strip()
69
+ if not s or any(c.isalpha() for c in s):
70
+ return False
71
+ digits = _PHONE_DIGITS.sub("", s.lstrip("+"))
72
+ return len(digits) >= 7
73
+
74
+
75
+ def _phone_key(address: str) -> str:
76
+ """Canonical phone digits, retaining international country codes."""
77
+ digits = _PHONE_DIGITS.sub("", (address or "").split('@', 1)[0])
78
+ # The supported bare NANP form may omit +1; other country codes stay intact.
79
+ return '1' + digits if len(digits) == 10 and not (address or '').strip().startswith('+') else digits
80
+
81
+
82
+ def _name_similarity(a: Optional[str], b: Optional[str]) -> float:
83
+ """Simple character bigram similarity in [0, 1]."""
84
+ if not a or not b:
85
+ return 0.0
86
+ a, b = a.lower(), b.lower()
87
+ if a == b:
88
+ return 1.0
89
+
90
+ def bigrams(s):
91
+ return {s[i:i+2] for i in range(len(s) - 1)}
92
+
93
+ bg_a, bg_b = bigrams(a), bigrams(b)
94
+ if not bg_a or not bg_b:
95
+ return 0.0
96
+ return 2.0 * len(bg_a & bg_b) / (len(bg_a) + len(bg_b))
97
+
98
+
99
+ # ── Abstract interface ────────────────────────────────────────────────────────
100
+
101
+ class ContactStore(ABC):
102
+ """Primary read/write interface for the contact store."""
103
+
104
+ @abstractmethod
105
+ async def get(self, contact_id: str) -> Optional[Contact]:
106
+ """Fetch a contact by canonical ID. Returns None if not found or deleted."""
107
+
108
+ @abstractmethod
109
+ async def resolve_handle(self, gateway: str, address: str) -> Optional[Contact]:
110
+ """Resolve a gateway handle to a Contact."""
111
+
112
+ async def resolve_verified_handles(self, gateway: str, addresses: List[str]) -> Optional[Contact]:
113
+ """Resolve exact transport aliases only when they identify one verified contact."""
114
+ raise NotImplementedError
115
+
116
+ @abstractmethod
117
+ async def create(
118
+ self,
119
+ display_name: Optional[str] = None,
120
+ given_name: Optional[str] = None,
121
+ family_name: Optional[str] = None,
122
+ organization: Optional[str] = None,
123
+ trust_tier: str = "unknown",
124
+ interaction_allowed: Optional[bool] = None,
125
+ tags: Optional[List[str]] = None,
126
+ privacy_level: str = "private",
127
+ import_source: str = "manual",
128
+ notes: Optional[str] = None,
129
+ ) -> Contact:
130
+ """Create a new contact record."""
131
+
132
+ @abstractmethod
133
+ async def add_handle(
134
+ self,
135
+ contact_id: str,
136
+ gateway: str,
137
+ address: str,
138
+ is_primary: bool = False,
139
+ confidence: float = 1.0,
140
+ source: str = "manual",
141
+ verified: bool = False,
142
+ ) -> ContactHandle:
143
+ """Add a gateway handle to an existing contact."""
144
+
145
+ @abstractmethod
146
+ async def provision_verified_handle(
147
+ self,
148
+ *,
149
+ operation_id: str,
150
+ performed_by: str,
151
+ gateway: str,
152
+ address: str,
153
+ display_name: Optional[str] = None,
154
+ contact_id: Optional[str] = None,
155
+ ) -> Dict[str, Any]:
156
+ """Atomically create/map one exact owner-verified contact handle.
157
+
158
+ Exactly one of ``display_name`` (create) and ``contact_id`` (map) is
159
+ required. Creation is always inert (``interaction_allowed=false``).
160
+ ``operation_id`` is a durable idempotency key bound to the exact input
161
+ and authenticated principal.
162
+ """
163
+
164
+ @abstractmethod
165
+ async def get_handles(self, contact_id: str) -> List[ContactHandle]:
166
+ """Return all handles for a contact."""
167
+
168
+ @abstractmethod
169
+ async def update_tier(
170
+ self,
171
+ contact_id: str,
172
+ new_tier: str,
173
+ reason: Optional[str] = None,
174
+ performed_by: str = "operator",
175
+ ) -> None:
176
+ """Update a contact's trust tier and record the change in audit."""
177
+
178
+ @abstractmethod
179
+ async def update_relationship_score(self, contact_id: str, score: float) -> None:
180
+ """Update the relationship_score for a contact (0.0–1.0)."""
181
+
182
+ @abstractmethod
183
+ async def update_interaction_allowed(
184
+ self, contact_id: str, allowed: bool, performed_by: str = "operator"
185
+ ) -> None:
186
+ """Toggle the interaction_allowed flag."""
187
+
188
+ @abstractmethod
189
+ async def soft_delete(
190
+ self, contact_id: str, reason: Optional[str] = None, performed_by: str = "operator"
191
+ ) -> None:
192
+ """Soft-delete a contact."""
193
+
194
+ @abstractmethod
195
+ async def hard_delete(self, contact_id: str, performed_by: str = "system") -> None:
196
+ """Permanently delete a contact record."""
197
+
198
+ @abstractmethod
199
+ async def list(
200
+ self,
201
+ trust_tier: Optional[str] = None,
202
+ interaction_allowed: Optional[bool] = None,
203
+ tag: Optional[str] = None,
204
+ include_deleted: bool = False,
205
+ limit: int = 100,
206
+ offset: int = 0,
207
+ ) -> List[Contact]:
208
+ """List contacts with optional filtering."""
209
+
210
+ @abstractmethod
211
+ async def find_by_name(self, name: str, threshold: float = 0.5) -> List[Contact]:
212
+ """Find contacts whose display_name is similar to name."""
213
+
214
+ @abstractmethod
215
+ async def merge_contacts(
216
+ self, keep_id: str, merge_id: str, performed_by: str = "owner",
217
+ ) -> Optional[Contact]:
218
+ """Merge one contact into another: reassign handles, fold interaction
219
+ history, soft-delete the merged record. Audited and reversible."""
220
+
221
+ @abstractmethod
222
+ async def list_handle_proposals(self, limit: int = 50) -> List[Dict[str, Any]]:
223
+ """Pending handle-link proposals (from scoped-name attribution) for
224
+ owner review: {contact_id, display_name, gateway, address, at}."""
225
+
226
+ @abstractmethod
227
+ async def update(self, contact_id: str, **fields) -> Optional[Contact]:
228
+ """Update arbitrary fields on a contact."""
229
+
230
+ @abstractmethod
231
+ async def record_audit(
232
+ self,
233
+ contact_id: str,
234
+ action: str,
235
+ detail: Optional[Dict[str, Any]] = None,
236
+ performed_by: str = "system",
237
+ ) -> None:
238
+ """Write an audit record."""
239
+
240
+ @abstractmethod
241
+ async def connect(self) -> None:
242
+ """Open the database connection."""
243
+
244
+ @abstractmethod
245
+ async def close(self) -> None:
246
+ """Close the database connection."""
247
+
248
+ async def __aenter__(self) -> "ContactStore":
249
+ await self.connect()
250
+ return self
251
+
252
+ async def __aexit__(self, *_) -> None:
253
+ await self.close()
254
+
255
+
256
+ # ── SQLite implementation ─────────────────────────────────────────────────────
257
+
258
+ class SQLiteContactStore(ContactStore):
259
+ """SQLite-backed implementation of ContactStore."""
260
+
261
+ def __init__(self, config: Optional[ContactsConfig] = None, graph=None) -> None:
262
+ self._config = config or ContactsConfig()
263
+ self._db: Optional[aiosqlite.Connection] = None
264
+ self._graph = graph # Optional ColonyGraph for score sync
265
+
266
+ async def connect(self) -> None:
267
+ path = self._config.sqlite_path
268
+ if path != ":memory:":
269
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
270
+ self._db = await aiosqlite.connect(path)
271
+ self._db.row_factory = aiosqlite.Row
272
+ # In-query phone identity key so messaging resolution matches a number to its contact even
273
+ # when stored handles are formatted differently / under a different gateway -- works without
274
+ # a data migration (the stored side is keyed at query time).
275
+ await self._db.create_function(
276
+ "phone_key", 1, lambda v: _phone_key(v) if v else "")
277
+ await self._db.execute("PRAGMA journal_mode=WAL")
278
+ await self._db.execute("PRAGMA foreign_keys=ON")
279
+ from apsimo.migrations import run_migrations
280
+ await run_migrations(self._db, _MIGRATIONS_DIR)
281
+ await self._apply_migrations()
282
+
283
+ async def _apply_migrations(self) -> None:
284
+ """Idempotent additive migrations for DBs created before a column existed.
285
+
286
+ SQLite has no ADD COLUMN IF NOT EXISTS, so we introspect first.
287
+ """
288
+ db = self._db
289
+ assert db is not None
290
+ async with db.execute("PRAGMA table_info(contacts)") as cur:
291
+ cols = {row[1] for row in await cur.fetchall()}
292
+ if "timezone" not in cols: # v0.21.0 — per-contact timezone
293
+ await db.execute("ALTER TABLE contacts ADD COLUMN timezone TEXT")
294
+ await db.commit()
295
+ # Introduction provenance (social-graph autonomy): who introduced this
296
+ # contact + how/where the agent met them. First-class on the contact so
297
+ # it is always available even when no world-model Person node exists yet.
298
+ if "introduced_by" not in cols:
299
+ await db.execute("ALTER TABLE contacts ADD COLUMN introduced_by TEXT")
300
+ await db.commit()
301
+ if "met_via_json" not in cols:
302
+ await db.execute("ALTER TABLE contacts ADD COLUMN met_via_json TEXT")
303
+ await db.commit()
304
+
305
+ async def close(self) -> None:
306
+ if self._db:
307
+ await self._db.close()
308
+ self._db = None
309
+
310
+ def _require_db(self) -> aiosqlite.Connection:
311
+ if not self._db:
312
+ raise RuntimeError("ContactStore not connected. Use async with or call connect().")
313
+ return self._db
314
+
315
+ async def _open_provision_connection(self) -> aiosqlite.Connection:
316
+ """Open one dedicated durable connection for a provisioning command.
317
+
318
+ Provisioning promises crash-safe idempotency. A process-local memory
319
+ database cannot provide that promise, and sharing ``self._db`` would
320
+ allow an unrelated coroutine's commit to persist a partial operation.
321
+ """
322
+
323
+ raw_path = str(self._config.sqlite_path or "").strip()
324
+ if (
325
+ not raw_path
326
+ or raw_path == ":memory:"
327
+ or raw_path.startswith("file:")
328
+ ):
329
+ raise RuntimeError(
330
+ "contact provisioning requires a durable file-backed store"
331
+ )
332
+ path = Path(raw_path).expanduser().resolve()
333
+ if not path.is_file():
334
+ raise RuntimeError(
335
+ "contact provisioning durable store is unavailable"
336
+ )
337
+ db = await aiosqlite.connect(str(path), timeout=5.0)
338
+ db.row_factory = aiosqlite.Row
339
+ await db.create_function(
340
+ "phone_key", 1, lambda value: _phone_key(value) if value else ""
341
+ )
342
+ await db.execute("PRAGMA foreign_keys=ON")
343
+ await db.execute("PRAGMA busy_timeout=5000")
344
+ return db
345
+
346
+ async def _after_provision_contact_insert(self) -> None:
347
+ """Internal fault-injection seam; production has no side effect."""
348
+
349
+ return None
350
+
351
+ # ── Read ops ──────────────────────────────────────────────────────────────
352
+
353
+ async def propose_handle_link(self, contact_id, gateway, address, *, evidence_refs=(), source='auto:scoped-name'):
354
+ from .identity_links import propose
355
+ return await propose(self, contact_id=contact_id, gateway=gateway, address=address,
356
+ evidence_refs=evidence_refs, source=source)
357
+
358
+ async def correct_handle_identity(self, **kwargs):
359
+ from .identity_links import correct
360
+ return await correct(self, **kwargs)
361
+
362
+ async def pending_identity_reconciliations(self, *, limit=100):
363
+ from .identity_links import pending_reconciliations
364
+ return await pending_reconciliations(self, limit=limit)
365
+
366
+ async def mark_sources_reconciled(self, operation_id, source_result):
367
+ from .identity_links import mark_sources_reconciled
368
+ return await mark_sources_reconciled(self, operation_id=operation_id, source_result=source_result)
369
+
370
+ async def mark_sources_conflicted(self, operation_id, code):
371
+ from .identity_links import mark_sources_conflicted
372
+ return await mark_sources_conflicted(self, operation_id=operation_id, code=code)
373
+
374
+ async def identity_evidence(self, contact_id):
375
+ from .identity_links import evidence
376
+ return await evidence(self, contact_id)
377
+
378
+ async def identity_revision(self):
379
+ async with self._require_db().execute('SELECT coalesce(max(rowid),0) FROM contact_identity_operations') as cur:
380
+ return int((await cur.fetchone())[0])
381
+
382
+ async def get(self, contact_id: str) -> Optional[Contact]:
383
+ db = self._require_db()
384
+ async with db.execute(
385
+ "SELECT * FROM contacts WHERE contact_id = ? AND deleted_at IS NULL",
386
+ (contact_id,),
387
+ ) as cur:
388
+ row = await cur.fetchone()
389
+ if row is None:
390
+ return None
391
+ return Contact.from_row(dict(row))
392
+
393
+ async def resolve_handle(self, gateway: str, address: str) -> Optional[Contact]:
394
+ db = self._require_db()
395
+ norm = _normalize_email(address) if gateway == "email" else _normalize_phone(address) if gateway in ("imessage", "sms", "signal") else address
396
+ async with db.execute(
397
+ """
398
+ SELECT c.* FROM contacts c
399
+ JOIN contact_handles h ON h.contact_id = c.contact_id
400
+ WHERE h.gateway = ? AND h.address = ? AND c.deleted_at IS NULL
401
+ AND (h.verified=1 OR h.source!='auto:scoped-name')
402
+ """,
403
+ (gateway, norm),
404
+ ) as cur:
405
+ row = await cur.fetchone()
406
+ if row is None:
407
+ return None
408
+ return Contact.from_row(dict(row))
409
+
410
+ async def resolve_messaging_handle(self, gateway: str, address: str) -> Optional[Contact]:
411
+ """Resolve a live sender, preferring a verified exact transport handle.
412
+
413
+ An explicit channel correction can split previously shared phone
414
+ attribution. Cross-gateway phone inference must not undo that decision.
415
+ Without an exact verified handle, retain the unambiguous normalized
416
+ phone/email fallback. This does not grant a contact any authority.
417
+ """
418
+ db = self._require_db()
419
+ g = (gateway or "").strip().lower()
420
+ if g == "rcs": # D1: RCS canonicalizes to the shared phone identity (no separate gateway)
421
+ g = "sms"
422
+ normalized = (_normalize_email(address) if g == 'email' else
423
+ _normalize_phone(address) if g in ('sms', 'imessage', 'signal') else address)
424
+ exact = await self.resolve_verified_handles(g, [normalized])
425
+ if exact is not None:
426
+ return exact
427
+ if g == "email":
428
+ sql = ("SELECT c.* FROM contacts c JOIN contact_handles h ON h.contact_id = c.contact_id "
429
+ "WHERE h.gateway = 'email' AND lower(h.address) = ? AND c.deleted_at IS NULL LIMIT 1")
430
+ params: tuple = (_normalize_email(address),)
431
+ elif g in _get_phone_gateways() or _looks_like_phone(address):
432
+ _pgw = tuple(_get_phone_gateways())
433
+ placeholders = ",".join("?" for _ in _pgw)
434
+ sql = ("SELECT c.* FROM contacts c JOIN contact_handles h ON h.contact_id = c.contact_id "
435
+ f"WHERE h.gateway IN ({placeholders}) AND phone_key(h.address) = ? "
436
+ "AND c.deleted_at IS NULL LIMIT 1")
437
+ params = (*_pgw, _phone_key(address))
438
+ else:
439
+ sql = ("SELECT c.* FROM contacts c JOIN contact_handles h ON h.contact_id = c.contact_id "
440
+ "WHERE h.gateway = ? AND h.address = ? AND c.deleted_at IS NULL LIMIT 1")
441
+ params = (g, address)
442
+ # Name-based legacy proposals must never become confirmed attribution.
443
+ # Multiple matching contacts are ambiguous, even on a normalized phone.
444
+ sql = sql.replace(' LIMIT 1', '') + " AND (h.verified=1 OR h.source!='auto:scoped-name')"
445
+ async with db.execute(sql, params) as cur:
446
+ rows = await cur.fetchall()
447
+ row = rows[0] if len({r['contact_id'] for r in rows}) == 1 else None
448
+ return Contact.from_row(dict(row)) if row else None
449
+
450
+ async def resolve_verified_handles(self, gateway: str, addresses: List[str]) -> Optional[Contact]:
451
+ values = list(dict.fromkeys(addresses))
452
+ if not 1 <= len(values) <= 5 or any(not isinstance(v, str) or not 1 <= len(v) <= 512 for v in values):
453
+ raise ValueError('bounded_exact_transport_aliases_required')
454
+ db = self._require_db()
455
+ async with db.execute('SELECT DISTINCT c.* FROM contacts c JOIN contact_handles h '
456
+ 'ON h.contact_id=c.contact_id WHERE c.deleted_at IS NULL AND h.verified=1 '
457
+ 'AND h.gateway=? AND h.address IN (' + ','.join('?' for _ in values) + ')',
458
+ [gateway, *values]) as cursor:
459
+ rows = await cursor.fetchall()
460
+ return Contact.from_row(dict(rows[0])) if len(rows) == 1 else None
461
+
462
+ async def get_handles(self, contact_id: str) -> List[ContactHandle]:
463
+ db = self._require_db()
464
+ async with db.execute(
465
+ "SELECT * FROM contact_handles WHERE contact_id = ? ORDER BY is_primary DESC, created_at",
466
+ (contact_id,),
467
+ ) as cur:
468
+ rows = await cur.fetchall()
469
+ return [ContactHandle.from_row(dict(r)) for r in rows]
470
+
471
+ async def list(
472
+ self,
473
+ trust_tier: Optional[str] = None,
474
+ interaction_allowed: Optional[bool] = None,
475
+ tag: Optional[str] = None,
476
+ include_deleted: bool = False,
477
+ limit: int = 100,
478
+ offset: int = 0,
479
+ ) -> List[Contact]:
480
+ db = self._require_db()
481
+ clauses = []
482
+ params: List[Any] = []
483
+ if not include_deleted:
484
+ clauses.append("deleted_at IS NULL")
485
+ if trust_tier:
486
+ clauses.append("trust_tier = ?")
487
+ params.append(trust_tier)
488
+ if interaction_allowed is not None:
489
+ clauses.append("interaction_allowed = ?")
490
+ params.append(1 if interaction_allowed else 0)
491
+ if tag:
492
+ # SQL-02: escape LIKE wildcards to prevent contact enumeration
493
+ safe_tag = tag.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
494
+ clauses.append("tags_json LIKE ? ESCAPE '\\'")
495
+ params.append(f'%"{safe_tag}"%')
496
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
497
+ params += [limit, offset]
498
+ async with db.execute(
499
+ f"SELECT * FROM contacts {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
500
+ params,
501
+ ) as cur:
502
+ rows = await cur.fetchall()
503
+ return [Contact.from_row(dict(r)) for r in rows]
504
+
505
+ async def find_by_name(self, name: str, threshold: float = 0.5) -> List[Contact]:
506
+ db = self._require_db()
507
+ async with db.execute(
508
+ "SELECT * FROM contacts WHERE deleted_at IS NULL",
509
+ ) as cur:
510
+ rows = await cur.fetchall()
511
+ results = []
512
+ for row in rows:
513
+ c = Contact.from_row(dict(row))
514
+ sim = _name_similarity(name, c.display_name)
515
+ if sim >= threshold:
516
+ results.append((sim, c))
517
+ results.sort(key=lambda x: x[0], reverse=True)
518
+ return [c for _, c in results]
519
+
520
+ async def find_by_person_node_id(self, person_node_id: str) -> Optional[Contact]:
521
+ """Fetch a contact by its linked Neo4j Person node ID."""
522
+ db = self._require_db()
523
+ async with db.execute(
524
+ "SELECT * FROM contacts WHERE person_node_id = ? AND deleted_at IS NULL",
525
+ (person_node_id,),
526
+ ) as cur:
527
+ row = await cur.fetchone()
528
+ if row is None:
529
+ return None
530
+ return Contact.from_row(dict(row))
531
+
532
+ async def find_discovered_by_handle(
533
+ self, gateway: str, address: str
534
+ ) -> Optional[Contact]:
535
+ """Find a discovered (world_model) contact that owns a given handle."""
536
+ db = self._require_db()
537
+ norm = _normalize_email(address) if gateway == "email" else _normalize_phone(address) if gateway in ("imessage", "sms", "signal") else address
538
+ async with db.execute(
539
+ """
540
+ SELECT c.* FROM contacts c
541
+ JOIN contact_handles h ON h.contact_id = c.contact_id
542
+ WHERE c.import_source = 'world_model'
543
+ AND c.deleted_at IS NULL
544
+ AND h.gateway = ? AND h.address = ?
545
+ LIMIT 1
546
+ """,
547
+ (gateway, norm),
548
+ ) as cur:
549
+ row = await cur.fetchone()
550
+ if row is None:
551
+ return None
552
+ return Contact.from_row(dict(row))
553
+
554
+ # ── Write ops ─────────────────────────────────────────────────────────────
555
+
556
+ async def create(
557
+ self,
558
+ display_name: Optional[str] = None,
559
+ given_name: Optional[str] = None,
560
+ family_name: Optional[str] = None,
561
+ organization: Optional[str] = None,
562
+ trust_tier: str = "unknown",
563
+ interaction_allowed: Optional[bool] = None,
564
+ tags: Optional[List[str]] = None,
565
+ privacy_level: str = "private",
566
+ import_source: str = "manual",
567
+ notes: Optional[str] = None,
568
+ introduced_by: Optional[str] = None,
569
+ met_via: Optional[Dict[str, Any]] = None,
570
+ ) -> Contact:
571
+ db = self._require_db()
572
+ if trust_tier not in TRUST_TIERS:
573
+ raise ValueError(f"Invalid trust_tier: {trust_tier}")
574
+ if interaction_allowed is None:
575
+ interaction_allowed = TIER_DEFAULT_INTERACTION.get(trust_tier, True)
576
+ contact_id = _gen_id("cid")
577
+ now = _now_iso()
578
+ dn = display_name
579
+ if not dn and (given_name or family_name):
580
+ dn = " ".join(p for p in [given_name, family_name] if p)
581
+ await db.execute(
582
+ """
583
+ INSERT INTO contacts
584
+ (contact_id, display_name, given_name, family_name, organization,
585
+ trust_tier, interaction_allowed, tags_json, privacy_level,
586
+ import_source, notes, introduced_by, met_via_json,
587
+ first_seen_at, created_at, updated_at)
588
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
589
+ """,
590
+ (
591
+ contact_id, dn, given_name, family_name, organization,
592
+ trust_tier, 1 if interaction_allowed else 0,
593
+ json.dumps(tags or []), privacy_level,
594
+ import_source, notes, introduced_by,
595
+ json.dumps(met_via) if met_via else None,
596
+ now, now, now,
597
+ ),
598
+ )
599
+ await db.commit()
600
+ audit = {"import_source": import_source}
601
+ if introduced_by:
602
+ audit["introduced_by"] = introduced_by
603
+ await self.record_audit(contact_id, "created", audit)
604
+ contact = await self.get(contact_id)
605
+ assert contact is not None
606
+ return contact
607
+
608
+ async def record_introduction(
609
+ self,
610
+ contact_id: str,
611
+ introduced_by: Optional[str] = None,
612
+ met_via: Optional[Dict[str, Any]] = None,
613
+ ) -> Optional[Contact]:
614
+ """Annotate an EXISTING contact with introduction provenance.
615
+
616
+ Used when an intro names someone Colony already knows: we record who
617
+ introduced them / how they were met without duplicating the contact, and
618
+ do NOT touch their trust_tier or interaction_allowed (an intro never
619
+ grants standing). Only fills blanks — an existing introduced_by/met_via
620
+ is preserved (first introduction wins).
621
+ """
622
+ db = self._require_db()
623
+ existing = await self.get(contact_id)
624
+ if existing is None:
625
+ return None
626
+ set_parts, params, details = [], [], {}
627
+ if introduced_by and not existing.introduced_by:
628
+ set_parts.append("introduced_by = ?")
629
+ params.append(introduced_by)
630
+ details["introduced_by"] = introduced_by
631
+ if met_via and not existing.met_via:
632
+ set_parts.append("met_via_json = ?")
633
+ params.append(json.dumps(met_via))
634
+ details["met_via"] = met_via
635
+ if not set_parts:
636
+ return existing
637
+ set_parts.append("updated_at = ?")
638
+ params.append(_now_iso())
639
+ params.append(contact_id)
640
+ await db.execute(
641
+ f"UPDATE contacts SET {', '.join(set_parts)} WHERE contact_id = ?",
642
+ params,
643
+ )
644
+ await db.commit()
645
+ await self.record_audit(contact_id, "introduction_recorded", details)
646
+ return await self.get(contact_id)
647
+
648
+ async def introduction_candidates(
649
+ self,
650
+ trust_floor: str = "regular",
651
+ owner_contact_id: Optional[str] = None,
652
+ limit: int = 10,
653
+ ) -> List[Dict[str, Any]]:
654
+ """Find pairs of contacts who plausibly should meet (social-graph autonomy).
655
+
656
+ A candidate pair shares an organization (a "related work" signal) and BOTH
657
+ sides sit at or above ``trust_floor`` — the agent only proposes connecting
658
+ people it has standing with. The owner is never a candidate (the owner is
659
+ served, not introduced). Soft-deleted contacts are excluded. Returns at most
660
+ ``limit`` ordered pairs; the autonomy loop turns each into an owner-approved
661
+ INTRODUCTION proposal (never an auto-executed action).
662
+ """
663
+ from .models import _TIER_RANK
664
+
665
+ db = self._require_db()
666
+ floor_rank = _TIER_RANK.get(trust_floor, _TIER_RANK["regular"])
667
+ allowed = [t for t, r in _TIER_RANK.items() if r >= floor_rank]
668
+ if not allowed:
669
+ return []
670
+ ph = ",".join("?" for _ in allowed)
671
+ owner = owner_contact_id or ""
672
+ sql = f"""
673
+ SELECT a.contact_id AS a_id, a.display_name AS a_name,
674
+ b.contact_id AS b_id, b.display_name AS b_name,
675
+ a.organization AS org
676
+ FROM contacts a
677
+ JOIN contacts b
678
+ ON lower(a.organization) = lower(b.organization)
679
+ AND a.contact_id < b.contact_id
680
+ WHERE a.deleted_at IS NULL AND b.deleted_at IS NULL
681
+ AND a.organization IS NOT NULL AND trim(a.organization) != ''
682
+ AND a.trust_tier IN ({ph}) AND b.trust_tier IN ({ph})
683
+ AND a.contact_id != ? AND b.contact_id != ?
684
+ ORDER BY lower(a.organization), a.contact_id, b.contact_id
685
+ LIMIT ?
686
+ """
687
+ params = [*allowed, *allowed, owner, owner, limit]
688
+ async with db.execute(sql, params) as cur:
689
+ rows = await cur.fetchall()
690
+ return [
691
+ {
692
+ "a_id": r["a_id"], "a_name": r["a_name"],
693
+ "b_id": r["b_id"], "b_name": r["b_name"],
694
+ "organization": r["org"],
695
+ }
696
+ for r in rows
697
+ ]
698
+
699
+ async def add_handle(
700
+ self,
701
+ contact_id: str,
702
+ gateway: str,
703
+ address: str,
704
+ is_primary: bool = False,
705
+ confidence: float = 1.0,
706
+ source: str = "manual",
707
+ verified: bool = False,
708
+ ) -> ContactHandle:
709
+ db = self._require_db()
710
+ # Normalize address
711
+ if gateway == "email":
712
+ address = _normalize_email(address)
713
+ elif gateway in ("imessage", "sms", "signal"):
714
+ address = _normalize_phone(address)
715
+
716
+ # Check if address already belongs to another contact
717
+ async with db.execute(
718
+ "SELECT contact_id FROM contact_handles WHERE gateway = ? AND address = ?",
719
+ (gateway, address),
720
+ ) as cur:
721
+ existing = await cur.fetchone()
722
+ if existing and existing["contact_id"] != contact_id:
723
+ raise ValueError(
724
+ f"Handle ({gateway}, {address}) is already assigned to contact {existing['contact_id']}"
725
+ )
726
+ if existing and existing["contact_id"] == contact_id:
727
+ # Already exists for this contact — return it
728
+ async with db.execute(
729
+ "SELECT * FROM contact_handles WHERE gateway = ? AND address = ?",
730
+ (gateway, address),
731
+ ) as cur:
732
+ row = await cur.fetchone()
733
+ return ContactHandle.from_row(dict(row))
734
+
735
+ handle_id = _gen_id("hdl")
736
+ now = _now_iso()
737
+ await db.execute(
738
+ """
739
+ INSERT INTO contact_handles
740
+ (handle_id, contact_id, gateway, address, is_primary, verified, confidence, source, created_at)
741
+ VALUES (?,?,?,?,?,?,?,?,?)
742
+ """,
743
+ (handle_id, contact_id, gateway, address, 1 if is_primary else 0,
744
+ 1 if verified else 0, confidence, source, now),
745
+ )
746
+ await db.commit()
747
+ await self.record_audit(
748
+ contact_id, "handle_added",
749
+ {"gateway": gateway, "address": address, "source": source},
750
+ )
751
+ async with db.execute(
752
+ "SELECT * FROM contact_handles WHERE handle_id = ?", (handle_id,)
753
+ ) as cur:
754
+ row = await cur.fetchone()
755
+ return ContactHandle.from_row(dict(row))
756
+
757
+ async def provision_verified_handle(
758
+ self,
759
+ *,
760
+ operation_id: str,
761
+ performed_by: str,
762
+ gateway: str,
763
+ address: str,
764
+ display_name: Optional[str] = None,
765
+ contact_id: Optional[str] = None,
766
+ ) -> Dict[str, Any]:
767
+ """Atomically create/map an exact verified handle with an audit receipt.
768
+
769
+ This is intentionally generic at the store boundary. The API layer
770
+ decides which gateway/address grammar and which authenticated principal
771
+ are authorized. The store guarantees that create+handle cannot leave
772
+ an orphan contact, that an exact handle cannot cross contacts, and that
773
+ an operation retry cannot repeat or change the original mutation.
774
+ """
775
+
776
+ shared_db = self._require_db()
777
+ operation = str(operation_id or "")
778
+ principal = str(performed_by or "")
779
+ exact_gateway = str(gateway or "")
780
+ exact_address = str(address or "")
781
+ create_name = display_name if isinstance(display_name, str) else None
782
+ selected_id = contact_id if isinstance(contact_id, str) else None
783
+ creating = create_name is not None and selected_id is None
784
+ mapping = selected_id is not None and create_name is None
785
+ if not (creating or mapping):
786
+ raise ValueError("exactly one of display_name and contact_id is required")
787
+ if (
788
+ not operation
789
+ or operation != operation.strip()
790
+ or not principal
791
+ or principal != principal.strip()
792
+ or not exact_gateway
793
+ or exact_gateway != exact_gateway.strip()
794
+ or not exact_address
795
+ or exact_address != exact_address.strip()
796
+ ):
797
+ raise ValueError("provisioning identifiers must be exact non-empty text")
798
+ if creating and (
799
+ not create_name
800
+ or create_name != create_name.strip()
801
+ or any(
802
+ ord(character) < 0x20 or ord(character) == 0x7F
803
+ for character in create_name
804
+ )
805
+ ):
806
+ raise ValueError("display_name must be canonical text")
807
+ if mapping and (
808
+ not selected_id
809
+ or selected_id != selected_id.strip()
810
+ ):
811
+ raise ValueError("contact_id must be canonical text")
812
+
813
+ request_value = {
814
+ "contact_id": selected_id,
815
+ "display_name": create_name,
816
+ "gateway": exact_gateway,
817
+ "address": exact_address,
818
+ }
819
+ request_sha256 = hashlib.sha256(json.dumps(
820
+ request_value,
821
+ sort_keys=True,
822
+ separators=(",", ":"),
823
+ ensure_ascii=False,
824
+ ).encode("utf-8")).hexdigest()
825
+ now = _now_iso()
826
+
827
+ # connect() applies migrations on the shared connection. Prove the
828
+ # idempotency ledger exists before opening the dedicated operation
829
+ # connection; never create or migrate schema in the mutation window.
830
+ async with shared_db.execute(
831
+ "SELECT 1 FROM sqlite_master "
832
+ "WHERE type = 'table' AND name = 'contact_provision_operations'"
833
+ ) as cur:
834
+ migration_ready = await cur.fetchone()
835
+ if migration_ready is None:
836
+ raise RuntimeError("contact provisioning migration is unavailable")
837
+
838
+ db = await self._open_provision_connection()
839
+ try:
840
+ await db.execute("BEGIN IMMEDIATE")
841
+ try:
842
+ async with db.execute(
843
+ "SELECT request_sha256, performed_by, result_json "
844
+ "FROM contact_provision_operations WHERE operation_id = ?",
845
+ (operation,),
846
+ ) as cur:
847
+ prior = await cur.fetchone()
848
+ if prior is not None:
849
+ if (
850
+ prior["request_sha256"] != request_sha256
851
+ or prior["performed_by"] != principal
852
+ ):
853
+ raise ValueError(
854
+ "operation_id is already bound to another "
855
+ "provisioning request"
856
+ )
857
+ try:
858
+ result = json.loads(prior["result_json"])
859
+ except (TypeError, ValueError) as exc:
860
+ raise RuntimeError(
861
+ "stored provisioning receipt is invalid"
862
+ ) from exc
863
+ if not isinstance(result, dict):
864
+ raise RuntimeError(
865
+ "stored provisioning receipt is invalid"
866
+ )
867
+ await db.commit()
868
+ return result
869
+
870
+ # Exact uniqueness remains authoritative for every gateway.
871
+ async with db.execute(
872
+ "SELECT contact_id, handle_id, verified "
873
+ "FROM contact_handles WHERE gateway = ? AND address = ?",
874
+ (exact_gateway, exact_address),
875
+ ) as cur:
876
+ existing_handle = await cur.fetchone()
877
+
878
+ # Phone identity is canonical across every configured
879
+ # phone-bearing gateway. RCS is explicit because inbound RCS
880
+ # canonicalizes to SMS while legacy databases can retain rcs.
881
+ phone_gateways = tuple(sorted(
882
+ set(_get_phone_gateways()) | {"rcs"}
883
+ ))
884
+ phone_equivalents = []
885
+ if exact_gateway in phone_gateways:
886
+ identity_key = _phone_key(exact_address)
887
+ if len(identity_key) < 7:
888
+ raise ValueError(
889
+ "phone-bearing handle has no canonical phone identity"
890
+ )
891
+ placeholders = ",".join("?" for _ in phone_gateways)
892
+ async with db.execute(
893
+ "SELECT h.contact_id, h.handle_id, h.gateway, "
894
+ "h.address, h.verified FROM contact_handles h "
895
+ "JOIN contacts c ON c.contact_id = h.contact_id "
896
+ f"WHERE h.gateway IN ({placeholders}) "
897
+ "AND phone_key(h.address) = ? "
898
+ "AND c.deleted_at IS NULL "
899
+ "ORDER BY h.contact_id, h.handle_id",
900
+ (*phone_gateways, identity_key),
901
+ ) as cur:
902
+ phone_equivalents = await cur.fetchall()
903
+
904
+ created_contact = False
905
+ if creating:
906
+ assert create_name is not None
907
+ if existing_handle is not None:
908
+ raise ValueError("exact handle is already assigned")
909
+ if phone_equivalents:
910
+ raise ValueError(
911
+ "phone-equivalent handle is already assigned"
912
+ )
913
+ async with db.execute(
914
+ "SELECT contact_id, display_name FROM contacts "
915
+ "WHERE deleted_at IS NULL "
916
+ "AND display_name IS NOT NULL"
917
+ ) as cur:
918
+ named_contacts = await cur.fetchall()
919
+ if any(
920
+ str(row["display_name"] or "").casefold()
921
+ == create_name.casefold()
922
+ for row in named_contacts
923
+ ):
924
+ raise ValueError("display_name is not unique")
925
+ selected_id = _gen_id("cid")
926
+ await db.execute(
927
+ """
928
+ INSERT INTO contacts
929
+ (contact_id, display_name, trust_tier,
930
+ interaction_allowed, tags_json, privacy_level,
931
+ import_source, first_seen_at, created_at, updated_at)
932
+ VALUES (?, ?, 'unknown', 0, '[]', 'private',
933
+ 'owner_operator', ?, ?, ?)
934
+ """,
935
+ (selected_id, create_name, now, now, now),
936
+ )
937
+ created_contact = True
938
+ await self._after_provision_contact_insert()
939
+ await db.execute(
940
+ "INSERT INTO contact_audit "
941
+ "(id, contact_id, action, detail, performed_by, "
942
+ "created_at) VALUES (?, ?, 'created', ?, ?, ?)",
943
+ (
944
+ _gen_id("cau"),
945
+ selected_id,
946
+ json.dumps({"import_source": "owner_operator"}),
947
+ principal,
948
+ now,
949
+ ),
950
+ )
951
+ else:
952
+ assert selected_id is not None
953
+ async with db.execute(
954
+ "SELECT display_name, interaction_allowed "
955
+ "FROM contacts WHERE contact_id = ? "
956
+ "AND deleted_at IS NULL",
957
+ (selected_id,),
958
+ ) as cur:
959
+ selected = await cur.fetchone()
960
+ if selected is None:
961
+ raise ValueError("selected contact does not exist")
962
+ create_name = selected["display_name"]
963
+
964
+ assert selected_id is not None
965
+ if (
966
+ existing_handle is not None
967
+ and existing_handle["contact_id"] != selected_id
968
+ ):
969
+ raise ValueError(
970
+ "exact handle is already assigned to another contact"
971
+ )
972
+ if any(
973
+ row["contact_id"] != selected_id
974
+ for row in phone_equivalents
975
+ ):
976
+ raise ValueError(
977
+ "phone-equivalent handle is already assigned to "
978
+ "another contact"
979
+ )
980
+
981
+ handle_created = existing_handle is None
982
+ handle_changed = (
983
+ handle_created or not bool(existing_handle["verified"])
984
+ )
985
+ if handle_created:
986
+ handle_id = _gen_id("hdl")
987
+ await db.execute(
988
+ """
989
+ INSERT INTO contact_handles
990
+ (handle_id, contact_id, gateway, address, is_primary,
991
+ verified, confidence, source, created_at)
992
+ VALUES (?, ?, ?, ?, 1, 1, 1.0,
993
+ 'owner_operator', ?)
994
+ """,
995
+ (
996
+ handle_id,
997
+ selected_id,
998
+ exact_gateway,
999
+ exact_address,
1000
+ now,
1001
+ ),
1002
+ )
1003
+ else:
1004
+ handle_id = str(existing_handle["handle_id"])
1005
+ if handle_changed:
1006
+ await db.execute(
1007
+ "UPDATE contact_handles SET verified = 1, "
1008
+ "confidence = 1.0, source = 'owner_operator' "
1009
+ "WHERE handle_id = ?",
1010
+ (handle_id,),
1011
+ )
1012
+
1013
+ address_sha256 = hashlib.sha256(
1014
+ exact_address.encode("utf-8")
1015
+ ).hexdigest()
1016
+ await db.execute(
1017
+ "INSERT INTO contact_audit "
1018
+ "(id, contact_id, action, detail, performed_by, created_at) "
1019
+ "VALUES (?, ?, 'contact_handle_owner_verified', ?, ?, ?)",
1020
+ (
1021
+ _gen_id("cau"),
1022
+ selected_id,
1023
+ json.dumps({
1024
+ "operation_id": operation,
1025
+ "gateway": exact_gateway,
1026
+ "address_sha256": address_sha256,
1027
+ "handle_created": handle_created,
1028
+ "verification_changed": handle_changed,
1029
+ "contact_created": created_contact,
1030
+ }, sort_keys=True),
1031
+ principal,
1032
+ now,
1033
+ ),
1034
+ )
1035
+
1036
+ # Read standing in the same transaction. This method never
1037
+ # enables it; creation is always held.
1038
+ async with db.execute(
1039
+ "SELECT interaction_allowed FROM contacts "
1040
+ "WHERE contact_id = ?",
1041
+ (selected_id,),
1042
+ ) as cur:
1043
+ standing_row = await cur.fetchone()
1044
+ if standing_row is None:
1045
+ raise RuntimeError("provisioned contact disappeared")
1046
+ result = {
1047
+ "contact_id": selected_id,
1048
+ "display_name": create_name,
1049
+ "gateway": exact_gateway,
1050
+ "address": exact_address,
1051
+ "handle_id": handle_id,
1052
+ "created": created_contact,
1053
+ "handle_created": handle_created,
1054
+ "changed": created_contact or handle_changed,
1055
+ "verified": True,
1056
+ "interaction_allowed": bool(
1057
+ standing_row["interaction_allowed"]
1058
+ ),
1059
+ "operation_id": operation,
1060
+ }
1061
+ result_json = json.dumps(
1062
+ result,
1063
+ sort_keys=True,
1064
+ separators=(",", ":"),
1065
+ ensure_ascii=False,
1066
+ )
1067
+ await db.execute(
1068
+ "INSERT INTO contact_provision_operations "
1069
+ "(operation_id, request_sha256, performed_by, contact_id, "
1070
+ "result_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
1071
+ (
1072
+ operation,
1073
+ request_sha256,
1074
+ principal,
1075
+ selected_id,
1076
+ result_json,
1077
+ now,
1078
+ ),
1079
+ )
1080
+ await db.commit()
1081
+ return result
1082
+ except Exception:
1083
+ await db.rollback()
1084
+ raise
1085
+ finally:
1086
+ await db.close()
1087
+
1088
+ async def update_tier(
1089
+ self,
1090
+ contact_id: str,
1091
+ new_tier: str,
1092
+ reason: Optional[str] = None,
1093
+ performed_by: str = "operator",
1094
+ ) -> None:
1095
+ db = self._require_db()
1096
+ if new_tier not in TRUST_TIERS:
1097
+ raise ValueError(f"Invalid trust_tier: {new_tier}")
1098
+ async with db.execute(
1099
+ "SELECT trust_tier FROM contacts WHERE contact_id = ?", (contact_id,)
1100
+ ) as cur:
1101
+ row = await cur.fetchone()
1102
+ if not row:
1103
+ raise ValueError(f"Contact not found: {contact_id}")
1104
+ old_tier = row["trust_tier"]
1105
+ await db.execute(
1106
+ "UPDATE contacts SET trust_tier = ?, updated_at = ? WHERE contact_id = ?",
1107
+ (new_tier, _now_iso(), contact_id),
1108
+ )
1109
+ await db.commit()
1110
+ await self.record_audit(
1111
+ contact_id, "tier_changed",
1112
+ {"old_tier": old_tier, "new_tier": new_tier, "reason": reason},
1113
+ performed_by=performed_by,
1114
+ )
1115
+
1116
+ async def update_relationship_score(self, contact_id: str, score: float) -> None:
1117
+ db = self._require_db()
1118
+ score = max(0.0, min(1.0, score))
1119
+ await db.execute(
1120
+ "UPDATE contacts SET relationship_score = ?, updated_at = ? WHERE contact_id = ?",
1121
+ (score, _now_iso(), contact_id),
1122
+ )
1123
+ await db.commit()
1124
+ # Sync to graph if linked
1125
+ if self._graph is not None:
1126
+ try:
1127
+ contact = await self.get(contact_id)
1128
+ if contact and contact.person_node_id:
1129
+ await self._graph.update_person(
1130
+ contact.person_node_id, score=score,
1131
+ )
1132
+ except Exception as exc:
1133
+ logger.debug("Score sync to graph failed for %s: %s", contact_id, exc)
1134
+
1135
+ async def update_interaction_allowed(
1136
+ self, contact_id: str, allowed: bool, performed_by: str = "operator"
1137
+ ) -> None:
1138
+ db = self._require_db()
1139
+ await db.execute(
1140
+ "UPDATE contacts SET interaction_allowed = ?, updated_at = ? WHERE contact_id = ?",
1141
+ (1 if allowed else 0, _now_iso(), contact_id),
1142
+ )
1143
+ await db.commit()
1144
+ await self.record_audit(
1145
+ contact_id, "interaction_toggled",
1146
+ {"interaction_allowed": allowed},
1147
+ performed_by=performed_by,
1148
+ )
1149
+
1150
+ async def compute_cadence_overdue(
1151
+ self,
1152
+ *,
1153
+ now_iso: Optional[str] = None,
1154
+ default_cadence_days: float = 7.0,
1155
+ factor: float = 1.5,
1156
+ min_silence_days: float = 2.0,
1157
+ overdue_only: bool = True,
1158
+ limit: int = 20,
1159
+ exclude_ids: Optional[set] = None,
1160
+ ) -> List[Dict[str, Any]]:
1161
+ """Per-contact rhythm + silence (v0.21.0), SQLite-based.
1162
+
1163
+ Estimates each contact's typical cadence from their own interaction
1164
+ history (active span / interactions) and flags those overdue relative
1165
+ to *their* rhythm — so a daily contact is overdue after a few days while
1166
+ a monthly one isn't for weeks. Independent of the Neo4j graph.
1167
+ """
1168
+ from apsimo.util import temporal as _t
1169
+ db = self._require_db()
1170
+ now = _t.parse_iso(now_iso) or _t.now_utc()
1171
+ exclude = set(exclude_ids or [])
1172
+ async with db.execute(
1173
+ "SELECT contact_id, display_name, given_name, first_seen_at, "
1174
+ "last_interaction_at, interaction_count, timezone "
1175
+ "FROM contacts WHERE deleted_at IS NULL AND interaction_allowed = 1 "
1176
+ "AND last_interaction_at IS NOT NULL"
1177
+ ) as cur:
1178
+ rows = await cur.fetchall()
1179
+
1180
+ out: List[Dict[str, Any]] = []
1181
+ for r in rows:
1182
+ cid = r["contact_id"]
1183
+ if cid in exclude:
1184
+ continue
1185
+ last = _t.parse_iso(r["last_interaction_at"])
1186
+ if last is None:
1187
+ continue
1188
+ first = _t.parse_iso(r["first_seen_at"])
1189
+ count = int(r["interaction_count"] or 0)
1190
+ days_since = (now - last).total_seconds() / 86400.0
1191
+ # cadence estimate
1192
+ if count >= 2 and first is not None and last > first:
1193
+ span = (last - first).total_seconds() / 86400.0
1194
+ cadence = span / max(count - 1, 1)
1195
+ else:
1196
+ cadence = default_cadence_days
1197
+ cadence = max(0.5, min(cadence, 90.0))
1198
+ threshold = max(min_silence_days, cadence * factor)
1199
+ is_overdue = days_since > threshold
1200
+ if overdue_only and not is_overdue:
1201
+ continue
1202
+ out.append({
1203
+ "contact_id": cid,
1204
+ "name": r["display_name"] or r["given_name"] or cid,
1205
+ "timezone": r["timezone"],
1206
+ "last_interaction_at": r["last_interaction_at"],
1207
+ "days_since": round(days_since, 1),
1208
+ "cadence_days": round(cadence, 1),
1209
+ "overdue": is_overdue,
1210
+ "overdue_ratio": round(days_since / max(cadence, 0.5), 2),
1211
+ })
1212
+
1213
+ out.sort(key=lambda x: x["overdue_ratio"], reverse=True)
1214
+ return out[:limit]
1215
+
1216
+ async def record_interaction(self, contact_id: str, at_iso: Optional[str] = None) -> bool:
1217
+ """Bump last_interaction_at (+count) for a contact. v0.21.0.
1218
+
1219
+ Returns True if a row was updated (i.e. the contact exists).
1220
+ """
1221
+ db = self._require_db()
1222
+ ts = at_iso or _now_iso()
1223
+ cur = await db.execute(
1224
+ "UPDATE contacts SET last_interaction_at = ?, "
1225
+ "interaction_count = interaction_count + 1, updated_at = ? "
1226
+ "WHERE contact_id = ? AND deleted_at IS NULL",
1227
+ (ts, ts, contact_id),
1228
+ )
1229
+ await db.commit()
1230
+ return cur.rowcount > 0
1231
+
1232
+ async def set_timezone(
1233
+ self, contact_id: str, timezone: Optional[str], performed_by: str = "operator"
1234
+ ) -> None:
1235
+ """Set (or clear, with None) a contact's IANA timezone. v0.21.0."""
1236
+ from apsimo.util.temporal import is_valid_timezone
1237
+ if timezone is not None and not is_valid_timezone(timezone):
1238
+ raise ValueError(f"Invalid IANA timezone: {timezone!r}")
1239
+ db = self._require_db()
1240
+ async with db.execute(
1241
+ "SELECT timezone FROM contacts WHERE contact_id = ?", (contact_id,)
1242
+ ) as cur:
1243
+ row = await cur.fetchone()
1244
+ if not row:
1245
+ raise ValueError(f"Contact not found: {contact_id}")
1246
+ old_tz = row["timezone"]
1247
+ await db.execute(
1248
+ "UPDATE contacts SET timezone = ?, updated_at = ? WHERE contact_id = ?",
1249
+ (timezone, _now_iso(), contact_id),
1250
+ )
1251
+ await db.commit()
1252
+ await self.record_audit(
1253
+ contact_id, "timezone_changed",
1254
+ {"old_timezone": old_tz, "new_timezone": timezone},
1255
+ performed_by=performed_by,
1256
+ )
1257
+
1258
+ async def soft_delete(
1259
+ self, contact_id: str, reason: Optional[str] = None, performed_by: str = "operator"
1260
+ ) -> None:
1261
+ db = self._require_db()
1262
+ now = _now_iso()
1263
+ await db.execute(
1264
+ "UPDATE contacts SET deleted_at = ?, updated_at = ? WHERE contact_id = ?",
1265
+ (now, now, contact_id),
1266
+ )
1267
+ await db.commit()
1268
+ await self.record_audit(
1269
+ contact_id, "soft_deleted", {"reason": reason}, performed_by=performed_by
1270
+ )
1271
+
1272
+ async def hard_delete(self, contact_id: str, performed_by: str = "system") -> None:
1273
+ db = self._require_db()
1274
+ await self.record_audit(
1275
+ contact_id, "hard_deleted", {}, performed_by=performed_by
1276
+ )
1277
+ await db.execute("DELETE FROM contacts WHERE contact_id = ?", (contact_id,))
1278
+ await db.commit()
1279
+
1280
+ async def merge_contacts(
1281
+ self, keep_id: str, merge_id: str, performed_by: str = "owner",
1282
+ ) -> Optional[Contact]:
1283
+ """Fold ``merge_id`` into ``keep_id``: move its handles, add its
1284
+ interaction count, then soft-delete it. Audited on both records so
1285
+ the merge is traceable and the loser is recoverable."""
1286
+ db = self._require_db()
1287
+ if keep_id == merge_id:
1288
+ return await self.get(keep_id)
1289
+ keep = await self.get(keep_id)
1290
+ loser = await self.get(merge_id)
1291
+ if keep is None or loser is None:
1292
+ raise ValueError("both contacts must exist to merge")
1293
+ now = _now_iso()
1294
+ moved = 0
1295
+ for h in await self.get_handles(merge_id):
1296
+ # Skip a handle the keeper already has (avoid a unique clash);
1297
+ # otherwise reassign it to the keeper.
1298
+ try:
1299
+ await db.execute(
1300
+ "UPDATE contact_handles SET contact_id = ? "
1301
+ "WHERE handle_id = ?", (keep_id, h.handle_id))
1302
+ moved += 1
1303
+ except Exception:
1304
+ # duplicate (gateway,address) already on keep -> drop the dup
1305
+ await db.execute(
1306
+ "DELETE FROM contact_handles WHERE handle_id = ?",
1307
+ (h.handle_id,))
1308
+ # Fold interaction history + keep the earlier last-seen.
1309
+ new_count = int(getattr(keep, "interaction_count", 0) or 0) +\
1310
+ int(getattr(loser, "interaction_count", 0) or 0)
1311
+ await db.execute(
1312
+ "UPDATE contacts SET interaction_count = ?, updated_at = ? "
1313
+ "WHERE contact_id = ?", (new_count, now, keep_id))
1314
+ await db.commit()
1315
+ await self.record_audit(
1316
+ keep_id, "merged_in",
1317
+ {"merged_contact_id": merge_id,
1318
+ "merged_display_name": loser.display_name,
1319
+ "handles_moved": moved},
1320
+ performed_by=performed_by)
1321
+ await self.soft_delete(
1322
+ merge_id, reason=f"merged into {keep_id}", performed_by=performed_by)
1323
+ await self.record_audit(
1324
+ merge_id, "merged_into", {"kept_contact_id": keep_id},
1325
+ performed_by=performed_by)
1326
+ return await self.get(keep_id)
1327
+
1328
+ async def list_handle_proposals(self, limit: int = 50) -> List[Dict[str, Any]]:
1329
+ """Pending candidate associations, never installed identity links."""
1330
+ db = self._require_db()
1331
+ async with db.execute(
1332
+ "SELECT p.*, c.display_name FROM contact_identity_candidates p "
1333
+ "JOIN contacts c ON c.contact_id=p.contact_id WHERE p.status='pending' "
1334
+ "AND c.deleted_at IS NULL ORDER BY p.created_at DESC LIMIT ?", (max(1, min(100, limit)),)) as cur:
1335
+ rows = [dict(row) for row in await cur.fetchall()]
1336
+ for row in rows:
1337
+ row['at'] = row['created_at']
1338
+ row['evidence_refs'] = json.loads(row.pop('evidence_refs_json'))
1339
+ return rows
1340
+
1341
+ async def update(self, contact_id: str, **fields) -> Optional[Contact]:
1342
+ db = self._require_db()
1343
+ allowed_fields = {
1344
+ "display_name", "given_name", "family_name", "organization",
1345
+ "notes", "person_node_id", "privacy_level",
1346
+ "last_interaction_at", "interaction_count",
1347
+ "enrichment_source", "enrichment_last_at",
1348
+ }
1349
+ set_parts = []
1350
+ params = []
1351
+ for k, v in fields.items():
1352
+ if k not in allowed_fields:
1353
+ continue
1354
+ if k == "enrichment_source" and isinstance(v, list):
1355
+ v = json.dumps(v)
1356
+ # SQL-01: column name is validated against allowed_fields; double-quote the
1357
+ # identifier so SQLite treats it safely even if allowed_fields is later extended.
1358
+ set_parts.append(f'"{k}" = ?')
1359
+ params.append(v)
1360
+ if not set_parts:
1361
+ return await self.get(contact_id)
1362
+ set_parts.append("updated_at = ?")
1363
+ params.append(_now_iso())
1364
+ params.append(contact_id)
1365
+ await db.execute(
1366
+ f"UPDATE contacts SET {', '.join(set_parts)} WHERE contact_id = ?",
1367
+ params,
1368
+ )
1369
+ await db.commit()
1370
+ return await self.get(contact_id)
1371
+
1372
+ async def record_audit(
1373
+ self,
1374
+ contact_id: str,
1375
+ action: str,
1376
+ detail: Optional[Dict[str, Any]] = None,
1377
+ performed_by: str = "system",
1378
+ ) -> None:
1379
+ db = self._require_db()
1380
+ audit_id = _gen_id("cau")
1381
+ now = _now_iso()
1382
+ await db.execute(
1383
+ "INSERT INTO contact_audit (id, contact_id, action, detail, performed_by, created_at) VALUES (?,?,?,?,?,?)",
1384
+ (audit_id, contact_id, action, json.dumps(detail or {}), performed_by, now),
1385
+ )
1386
+ await db.commit()
1387
+
1388
+ async def get_audit_log(self, contact_id: str, limit: int = 50) -> List[Dict[str, Any]]:
1389
+ db = self._require_db()
1390
+ async with db.execute(
1391
+ "SELECT * FROM contact_audit WHERE contact_id = ? ORDER BY created_at DESC LIMIT ?",
1392
+ (contact_id, limit),
1393
+ ) as cur:
1394
+ rows = await cur.fetchall()
1395
+ return [dict(r) for r in rows]
1396
+
1397
+ # ── Deduplication helpers ─────────────────────────────────────────────────
1398
+
1399
+ async def find_by_handle(self, gateway: str, address: str) -> Optional[Contact]:
1400
+ """Find contact by exact handle (including soft-deleted contacts)."""
1401
+ db = self._require_db()
1402
+ async with db.execute(
1403
+ """
1404
+ SELECT c.* FROM contacts c
1405
+ JOIN contact_handles h ON h.contact_id = c.contact_id
1406
+ WHERE h.gateway = ? AND h.address = ?
1407
+ """,
1408
+ (gateway, address),
1409
+ ) as cur:
1410
+ row = await cur.fetchone()
1411
+ if row is None:
1412
+ return None
1413
+ return Contact.from_row(dict(row))
1414
+
1415
+ async def find_dedup_candidates(
1416
+ self, given_name: Optional[str], family_name: Optional[str],
1417
+ phones: List[str], emails: List[str],
1418
+ ) -> List[tuple]:
1419
+ """Return list of (confidence, contact_id, reason) tuples."""
1420
+ candidates = []
1421
+ seen_ids: set = set()
1422
+
1423
+ # Normalize
1424
+ norm_phones = [_normalize_phone(p) for p in phones if p]
1425
+ norm_emails = [_normalize_email(e) for e in emails if e]
1426
+
1427
+ # 1. Exact phone match
1428
+ for phone in norm_phones:
1429
+ contact = await self.resolve_handle("imessage", phone)
1430
+ if contact is None:
1431
+ contact = await self.resolve_handle("sms", phone)
1432
+ if contact and contact.contact_id not in seen_ids:
1433
+ seen_ids.add(contact.contact_id)
1434
+ candidates.append((0.99, contact.contact_id, f"exact_phone:{phone}"))
1435
+
1436
+ # 2. Exact email match
1437
+ for email in norm_emails:
1438
+ contact = await self.resolve_handle("email", email)
1439
+ if contact and contact.contact_id not in seen_ids:
1440
+ seen_ids.add(contact.contact_id)
1441
+ candidates.append((0.99, contact.contact_id, f"exact_email:{email}"))
1442
+
1443
+ # 3. Fuzzy name similarity
1444
+ display = " ".join(p for p in [given_name, family_name] if p)
1445
+ if display:
1446
+ name_matches = await self.find_by_name(display, threshold=0.4)
1447
+ for c in name_matches:
1448
+ if c.contact_id not in seen_ids:
1449
+ sim = _name_similarity(display, c.display_name)
1450
+ if sim >= 0.4:
1451
+ seen_ids.add(c.contact_id)
1452
+ candidates.append((sim * 0.7, c.contact_id, f"name_similarity:{sim:.2f}"))
1453
+
1454
+ return sorted(candidates, key=lambda x: x[0], reverse=True)
1455
+
1456
+ # ── Trust scopes (context-scoped trust) ────────────────────────────────────
1457
+
1458
+ async def create_scope(
1459
+ self,
1460
+ *,
1461
+ scope_type: str = "group",
1462
+ platform: Optional[str] = None,
1463
+ external_id: Optional[str] = None,
1464
+ label: Optional[str] = None,
1465
+ granted_tier: str = "group_guest",
1466
+ created_by: str = "agent",
1467
+ ) -> TrustScope:
1468
+ """Create a trust scope, or return the existing active one for
1469
+ (platform, external_id) if it already exists (idempotent upsert)."""
1470
+ if granted_tier not in TRUST_TIERS:
1471
+ raise ValueError(f"invalid granted_tier: {granted_tier}")
1472
+ db = self._require_db()
1473
+ if platform is not None and external_id is not None:
1474
+ existing = await self.get_scope(platform=platform, external_id=external_id)
1475
+ if existing is not None:
1476
+ return existing
1477
+ scope_id = _gen_id("ts")
1478
+ now = _now_iso()
1479
+ await db.execute(
1480
+ "INSERT INTO trust_scopes (scope_id, scope_type, platform, external_id, label, "
1481
+ "granted_tier, created_by, active, created_at, updated_at) "
1482
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
1483
+ (scope_id, scope_type, platform, external_id, label, granted_tier, created_by, now, now),
1484
+ )
1485
+ await db.commit()
1486
+ return TrustScope(
1487
+ scope_id=scope_id, scope_type=scope_type, platform=platform, external_id=external_id,
1488
+ label=label, granted_tier=granted_tier, created_by=created_by, active=True,
1489
+ created_at=now, updated_at=now,
1490
+ )
1491
+
1492
+ async def get_scope(
1493
+ self,
1494
+ *,
1495
+ scope_id: Optional[str] = None,
1496
+ platform: Optional[str] = None,
1497
+ external_id: Optional[str] = None,
1498
+ ) -> Optional[TrustScope]:
1499
+ """Fetch a scope by scope_id, or by (platform, external_id)."""
1500
+ db = self._require_db()
1501
+ if scope_id is not None:
1502
+ sql, params = "SELECT * FROM trust_scopes WHERE scope_id = ?", (scope_id,)
1503
+ elif platform is not None and external_id is not None:
1504
+ sql = "SELECT * FROM trust_scopes WHERE platform = ? AND external_id = ?"
1505
+ params = (platform, external_id)
1506
+ else:
1507
+ raise ValueError("get_scope needs scope_id or (platform, external_id)")
1508
+ async with db.execute(sql, params) as cur:
1509
+ row = await cur.fetchone()
1510
+ return TrustScope.from_row(dict(row)) if row else None
1511
+
1512
+ async def add_scope_member(
1513
+ self, scope_id: str, contact_id: str, role: str = "member"
1514
+ ) -> None:
1515
+ """Add (or re-activate) a contact's membership in a scope."""
1516
+ db = self._require_db()
1517
+ await db.execute(
1518
+ "INSERT INTO scope_members (scope_id, contact_id, role, joined_at, left_at) "
1519
+ "VALUES (?, ?, ?, ?, NULL) "
1520
+ "ON CONFLICT(scope_id, contact_id) DO UPDATE SET role = excluded.role, left_at = NULL",
1521
+ (scope_id, contact_id, role, _now_iso()),
1522
+ )
1523
+ await db.commit()
1524
+ await self.record_audit(contact_id, "scope_member_added",
1525
+ {"scope_id": scope_id, "role": role}, performed_by="agent")
1526
+
1527
+ async def remove_scope_member(self, scope_id: str, contact_id: str) -> None:
1528
+ """Mark a member as having left the scope (soft; preserves history)."""
1529
+ db = self._require_db()
1530
+ await db.execute(
1531
+ "UPDATE scope_members SET left_at = ? WHERE scope_id = ? AND contact_id = ? AND left_at IS NULL",
1532
+ (_now_iso(), scope_id, contact_id),
1533
+ )
1534
+ await db.commit()
1535
+ await self.record_audit(contact_id, "scope_member_removed",
1536
+ {"scope_id": scope_id}, performed_by="agent")
1537
+
1538
+ async def scope_members(self, scope_id: str, *, current_only: bool = True) -> List[ScopeMember]:
1539
+ db = self._require_db()
1540
+ sql = "SELECT * FROM scope_members WHERE scope_id = ?"
1541
+ if current_only:
1542
+ sql += " AND left_at IS NULL"
1543
+ async with db.execute(sql, (scope_id,)) as cur:
1544
+ rows = await cur.fetchall()
1545
+ return [ScopeMember.from_row(dict(r)) for r in rows]
1546
+
1547
+ async def scopes_for_contact(self, contact_id: str, *, active_only: bool = True) -> List[TrustScope]:
1548
+ """All scopes a contact is a current member of (optionally active scopes only)."""
1549
+ db = self._require_db()
1550
+ sql = (
1551
+ "SELECT s.* FROM trust_scopes s "
1552
+ "JOIN scope_members m ON m.scope_id = s.scope_id "
1553
+ "WHERE m.contact_id = ? AND m.left_at IS NULL"
1554
+ )
1555
+ if active_only:
1556
+ sql += " AND s.active = 1"
1557
+ async with db.execute(sql, (contact_id,)) as cur:
1558
+ rows = await cur.fetchall()
1559
+ return [TrustScope.from_row(dict(r)) for r in rows]
1560
+
1561
+ async def is_authorized_in_scope(self, contact_id: str, scope_id: str) -> bool:
1562
+ """True iff the contact is a current member of the (active) scope.
1563
+ This is group-scoped authorization — it says nothing about 1:1 rights."""
1564
+ db = self._require_db()
1565
+ async with db.execute(
1566
+ "SELECT 1 FROM scope_members m JOIN trust_scopes s ON s.scope_id = m.scope_id "
1567
+ "WHERE m.scope_id = ? AND m.contact_id = ? AND m.left_at IS NULL AND s.active = 1",
1568
+ (scope_id, contact_id),
1569
+ ) as cur:
1570
+ return await cur.fetchone() is not None
1571
+
1572
+ async def deactivate_scope(self, scope_id: str) -> None:
1573
+ """Deactivate a scope (revokes group-trust for all members at once)."""
1574
+ db = self._require_db()
1575
+ members = await self.scope_members(scope_id, current_only=True)
1576
+ await db.execute(
1577
+ "UPDATE trust_scopes SET active = 0, updated_at = ? WHERE scope_id = ?",
1578
+ (_now_iso(), scope_id),
1579
+ )
1580
+ await db.commit()
1581
+ for m in members:
1582
+ await self.record_audit(m.contact_id, "scope_deactivated",
1583
+ {"scope_id": scope_id}, performed_by="agent")
1584
+
1585
+ async def group_promotion_candidates(
1586
+ self, *, min_interactions: int = 5, limit: int = 50
1587
+ ) -> List["Contact"]:
1588
+ """Current members of an ACTIVE scope with sustained contact but no global 1:1 rights yet.
1589
+ These are who the owner could promote (group_guest -> regular), or who get auto-promoted
1590
+ when ``auto_promote_group_to_1on1`` is on. Group membership alone never promotes."""
1591
+ db = self._require_db()
1592
+ async with db.execute(
1593
+ "SELECT DISTINCT c.* FROM contacts c "
1594
+ "JOIN scope_members m ON m.contact_id = c.contact_id AND m.left_at IS NULL "
1595
+ "JOIN trust_scopes s ON s.scope_id = m.scope_id AND s.active = 1 "
1596
+ "WHERE c.deleted_at IS NULL AND c.interaction_allowed = 0 "
1597
+ "AND c.interaction_count >= ? ORDER BY c.interaction_count DESC LIMIT ?",
1598
+ (int(min_interactions), int(limit)),
1599
+ ) as cur:
1600
+ rows = await cur.fetchall()
1601
+ return [Contact.from_row(dict(r)) for r in rows]
1602
+
1603
+ async def promote_scope_member(
1604
+ self, contact_id: str, *, to_tier: str = "regular", performed_by: str = "agent"
1605
+ ) -> bool:
1606
+ """Grant a group-scope member global 1:1 rights (tier >= ``to_tier`` + interaction
1607
+ allowed). Only ever RAISES standing; returns True iff something changed."""
1608
+ from apsimo.contacts.models import _TIER_RANK
1609
+ c = await self.get(contact_id)
1610
+ if c is None:
1611
+ return False
1612
+ changed = False
1613
+ if _TIER_RANK.get(c.trust_tier, 0) < _TIER_RANK.get(to_tier, 0):
1614
+ await self.update_tier(contact_id, to_tier, reason="promoted from group scope",
1615
+ performed_by=performed_by)
1616
+ changed = True
1617
+ if not c.interaction_allowed:
1618
+ await self.update_interaction_allowed(contact_id, True, performed_by=performed_by)
1619
+ changed = True
1620
+ if changed:
1621
+ await self.record_audit(contact_id, "scope_promoted_to_1on1",
1622
+ {"to_tier": to_tier}, performed_by=performed_by)
1623
+ return changed