pinecall-runtime 0.1.6__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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,65 @@
1
+ """An org's policy: retention, outbound calling rules, what a call says first; one row."""
2
+
3
+ from pinecall.postgres.pool import Pool
4
+ from pinecall.wire.rest.accounts import CallingHours, OrgPolicy, OrgPolicyRow
5
+
6
+ POLICY = """
7
+ SELECT retention_days, calling_from, calling_until, per_number_day, consent_everywhere,
8
+ disclosure, recording_notice, set_by, set_at
9
+ FROM org_policy WHERE org = %(org)s
10
+ """
11
+
12
+ PUT_POLICY = """
13
+ INSERT INTO org_policy (org, retention_days, calling_from, calling_until, per_number_day,
14
+ consent_everywhere, disclosure, recording_notice, set_by)
15
+ VALUES (%(org)s, %(retention_days)s, %(calling_from)s, %(calling_until)s, %(per_number_day)s,
16
+ %(consent_everywhere)s, %(disclosure)s, %(recording_notice)s, %(set_by)s)
17
+ ON CONFLICT (org) DO UPDATE SET retention_days = excluded.retention_days,
18
+ calling_from = excluded.calling_from, calling_until = excluded.calling_until,
19
+ per_number_day = excluded.per_number_day, consent_everywhere = excluded.consent_everywhere,
20
+ disclosure = excluded.disclosure, recording_notice = excluded.recording_notice,
21
+ set_by = excluded.set_by, set_at = now()
22
+ """
23
+
24
+
25
+ async def policy_of(pool: Pool, org: str) -> OrgPolicyRow:
26
+ """The org's policy, and who set it last; an org nobody set has the platform's defaults."""
27
+ async with pool.connection() as connection:
28
+ row = await (await connection.execute(POLICY, {"org": org})).fetchone()
29
+ if row is None:
30
+ return OrgPolicyRow(policy=OrgPolicy(), set_by=None, set_at=None)
31
+ hours = None
32
+ if row["calling_from"] is not None:
33
+ hours = CallingHours.model_validate(
34
+ {"from": row["calling_from"], "until": row["calling_until"]}
35
+ )
36
+ return OrgPolicyRow(
37
+ policy=OrgPolicy(
38
+ retention_days=row["retention_days"],
39
+ calling_hours=hours,
40
+ per_number_day=row["per_number_day"],
41
+ consent_everywhere=row["consent_everywhere"],
42
+ disclosure=row["disclosure"],
43
+ recording_notice=row["recording_notice"],
44
+ ),
45
+ set_by=str(row["set_by"]),
46
+ set_at=row["set_at"].timestamp(),
47
+ )
48
+
49
+
50
+ async def put_policy(pool: Pool, org: str, policy: OrgPolicy, *, by: str) -> None:
51
+ """Replace the org's policy whole."""
52
+ hours = policy.calling_hours
53
+ params = {
54
+ "org": org,
55
+ "retention_days": policy.retention_days,
56
+ "calling_from": None if hours is None else hours.from_,
57
+ "calling_until": None if hours is None else hours.until,
58
+ "per_number_day": policy.per_number_day,
59
+ "consent_everywhere": policy.consent_everywhere,
60
+ "disclosure": policy.disclosure,
61
+ "recording_notice": policy.recording_notice,
62
+ "set_by": by,
63
+ }
64
+ async with pool.connection() as connection:
65
+ await connection.execute(PUT_POLICY, params)
@@ -0,0 +1,48 @@
1
+ """Each distinct block of prompt an org's calls were told, kept once under its hash, read back."""
2
+
3
+ from collections import OrderedDict
4
+ from collections.abc import Collection
5
+
6
+ from pinecall.domain.agent import block_hash
7
+ from pinecall.postgres.pool import Pool
8
+
9
+ # The texts this process wrote, remembered so a block is written once and not per call; past it
10
+ # the oldest is forgotten and, met again, costs one insert that finds its row.
11
+ REMEMBERED = 10_000
12
+
13
+
14
+ KEPT = """
15
+ INSERT INTO prompts (org, hash, text) VALUES (%(org)s, %(hash)s, %(text)s)
16
+ ON CONFLICT (org, hash) DO NOTHING
17
+ """
18
+
19
+
20
+ TEXTS = "SELECT hash, text FROM prompts WHERE org = %(org)s AND hash = ANY(%(hashes)s)"
21
+
22
+
23
+ # Not durable on purpose: the table is the record, this spares it a round trip.
24
+ class Prompts:
25
+ """The blocks this gateway process has kept already, by org and hash."""
26
+
27
+ def __init__(self) -> None:
28
+ """Nothing kept yet."""
29
+ self.kept: OrderedDict[tuple[str, str], None] = OrderedDict()
30
+
31
+ async def keep(self, pool: Pool, org: str, text: str) -> None:
32
+ """Keep the text under its hash for the org, once; an empty block is nothing to keep."""
33
+ key = (org, block_hash(text))
34
+ if not text or key in self.kept:
35
+ return
36
+ async with pool.connection() as connection:
37
+ await connection.execute(KEPT, {"org": org, "hash": key[1], "text": text})
38
+ self.kept[key] = None
39
+ if len(self.kept) > REMEMBERED:
40
+ self.kept.popitem(last=False)
41
+
42
+
43
+ async def texts_of(pool: Pool, org: str, hashes: Collection[str]) -> dict[str, str]:
44
+ """The org's texts of these hashes, those it kept."""
45
+ async with pool.connection() as connection:
46
+ found = await connection.execute(TEXTS, {"org": org, "hashes": list(hashes)})
47
+ rows = await found.fetchall()
48
+ return {str(row["hash"]): str(row["text"]) for row in rows}
@@ -0,0 +1,73 @@
1
+ """Who read what: a key's or the operator's read of a call, a number or memory, once an hour."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+
6
+ import psycopg
7
+
8
+ from pinecall.domain.scope import Scope
9
+ from pinecall.postgres.pool import Pool
10
+ from pinecall.wire.rest.calls import ReadKind, ReadRow
11
+
12
+ # The same reader reading the same thing again within the hour is the same read; an org erased
13
+ # has no access log left to write to (a traceback still finds its calls' records).
14
+ RECORD = """
15
+ INSERT INTO reads (org, env, subject, what, reader)
16
+ SELECT %(org)s, %(env)s, %(subject)s, %(what)s, %(reader)s
17
+ WHERE EXISTS (SELECT 1 FROM orgs WHERE id = %(org)s) AND NOT EXISTS (
18
+ SELECT 1 FROM reads
19
+ WHERE org = %(org)s AND subject = %(subject)s AND what = %(what)s AND reader = %(reader)s
20
+ AND env IS NOT DISTINCT FROM %(env)s AND at > now() - interval '1 hour'
21
+ )
22
+ """
23
+
24
+ OF_ORG = """
25
+ SELECT subject, what, env, reader, at FROM reads
26
+ WHERE org = %(org)s AND (%(subject)s::text IS NULL OR subject = %(subject)s)
27
+ ORDER BY at DESC, id DESC
28
+ LIMIT %(limit)s
29
+ """
30
+
31
+ # Who reads, or erases, on the box's own behalf: off the box, or through its own doors.
32
+ OPERATOR = "operator"
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ A_PAGE = 200
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Read:
41
+ """One read: the call or the number, what of it, and who."""
42
+
43
+ subject: str
44
+ what: ReadKind
45
+ reader: str
46
+
47
+
48
+ # One statement, awaited: a read waits on its record and on nothing more. A record that cannot be
49
+ # written (the database away, the pool spent) is said in the log and the read goes on: an audit
50
+ # that fails a read would be a way to stop every read.
51
+ async def record(pool: Pool, scope: Scope, read: Read) -> None:
52
+ """Write the read down, unless the same reader read the same thing within the hour."""
53
+ params = {"org": scope.org, "env": scope.env, **vars(read)}
54
+ try:
55
+ async with pool.connection() as connection:
56
+ await connection.execute(RECORD, params)
57
+ except psycopg.Error:
58
+ logger.warning(
59
+ "the read of %s by %s went unrecorded; the read goes on",
60
+ read.subject,
61
+ read.reader,
62
+ exc_info=True,
63
+ )
64
+
65
+
66
+ async def of_org(
67
+ pool: Pool, org: str, *, subject: str | None = None, limit: int = A_PAGE
68
+ ) -> list[ReadRow]:
69
+ """The org's reads, newest first, of one call or number when named."""
70
+ params = {"org": org, "subject": subject, "limit": limit}
71
+ async with pool.connection() as connection:
72
+ rows = await (await connection.execute(OF_ORG, params)).fetchall()
73
+ return [ReadRow.model_validate({**row, "at": row["at"].timestamp()}) for row in rows]
@@ -0,0 +1,41 @@
1
+ """A recording's own key: made once per call, kept sealed under the vault, erased with the call."""
2
+
3
+ import base64
4
+
5
+ from cryptography.fernet import MultiFernet
6
+
7
+ from pinecall.postgres.pool import Pool
8
+ from pinecall.process.sealed_audio import new_key
9
+ from pinecall.tenancy.vault import opened, sealed
10
+
11
+ # The insert is the mint: of two asks at once, or a retry, the first key stands and is the answer.
12
+ # Asked again, the same key; asked for another org's call, nothing (the update's WHERE fails).
13
+ MINT = """
14
+ INSERT INTO recording_keys (call, org, sealed) VALUES (%(call)s, %(org)s, %(sealed)s)
15
+ ON CONFLICT (call) DO UPDATE SET call = excluded.call WHERE recording_keys.org = excluded.org
16
+ RETURNING sealed
17
+ """
18
+
19
+ KEY = "SELECT sealed FROM recording_keys WHERE call = %(call)s"
20
+
21
+
22
+ async def key_for(pool: Pool, vault: MultiFernet, org: str, call: str) -> bytes | None:
23
+ """The call's recording key, made now or before; None for another org's, or one unopened."""
24
+ fresh = sealed(vault, base64.urlsafe_b64encode(new_key()).decode())
25
+ async with pool.connection() as connection:
26
+ row = await (
27
+ await connection.execute(MINT, {"call": call, "org": org, "sealed": fresh})
28
+ ).fetchone()
29
+ return None if row is None else _opened(vault, str(row["sealed"]))
30
+
31
+
32
+ async def key_of(pool: Pool, vault: MultiFernet, call: str) -> bytes | None:
33
+ """The call's recording key; None when it has none, or the vault no longer opens it."""
34
+ async with pool.connection() as connection:
35
+ row = await (await connection.execute(KEY, {"call": call})).fetchone()
36
+ return None if row is None else _opened(vault, str(row["sealed"]))
37
+
38
+
39
+ def _opened(vault: MultiFernet, token: str) -> bytes | None:
40
+ secret = opened(vault, token)
41
+ return base64.urlsafe_b64decode(secret) if isinstance(secret, str) else None
@@ -0,0 +1,148 @@
1
+ """A verified key remembered on this gateway for seconds, forgotten once it or its person change."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from pinecall.domain.errors import NotAvailable
14
+ from pinecall.postgres.pool import Pool, box_task
15
+ from pinecall.process.signal import Signal
16
+ from pinecall.tenancy import keys
17
+ from pinecall.tenancy.keys import Bearer
18
+ from pinecall.tenancy.people import fingerprint
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # Verifying a key is a round trip per request, 15 % of a gateway's core under load (measured
24
+ # 2026-09-30). A key is remembered this long; a revocation or a change to its person is said on the
25
+ # signal and forgets it everywhere at once, so the seconds are only what a gateway that cannot hear
26
+ # the signal keeps, and what a change made outside the doors (a shell, psql) waits.
27
+ REMEMBERED_FOR_S = 5.0
28
+
29
+
30
+ # What every gateway hears when a key is revoked, or a person's standing changes (a role, an agent
31
+ # list, production, operator, disabled or removed): what it remembered of them no longer holds.
32
+ CHANNEL = "keys:forgotten"
33
+
34
+
35
+ RETRY_S = 1.0
36
+
37
+
38
+ class Forgotten(BaseModel):
39
+ """What to forget: one key by its fingerprint, or every key of a person."""
40
+
41
+ fingerprint: str | None = None
42
+ subject: str | None = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Remembered:
47
+ """A verified key, and the clock reading it is forgotten at."""
48
+
49
+ bearer: Bearer
50
+ until: float
51
+
52
+
53
+ class RememberedKeys:
54
+ """The keys this gateway verified lately, by fingerprint, and the revocations it hears."""
55
+
56
+ def __init__(
57
+ self,
58
+ pool: Pool,
59
+ signal: Signal,
60
+ *,
61
+ for_s: float = REMEMBERED_FOR_S,
62
+ clock: Callable[[], float] = time.monotonic,
63
+ ) -> None:
64
+ """Nothing remembered yet; `start` listens for revocations."""
65
+ self.pool = pool
66
+ self.signal = signal
67
+ self.for_s = for_s
68
+ self.clock = clock
69
+ self.kept: dict[str, Remembered] = {}
70
+ self.listening: asyncio.Task[None] | None = None
71
+ self.swept_at = clock()
72
+
73
+ async def start(self) -> None:
74
+ """Listen for revocations said by any gateway."""
75
+ if self.listening is None:
76
+ self.listening = box_task(self._listened())
77
+
78
+ async def close(self) -> None:
79
+ """Stop listening and forget everything."""
80
+ if self.listening is not None:
81
+ self.listening.cancel()
82
+ with contextlib.suppress(asyncio.CancelledError):
83
+ await self.listening
84
+ self.listening = None
85
+ self.kept.clear()
86
+
87
+ async def verify(self, bearer: str) -> Bearer | None:
88
+ """The key and its person, from memory within the seconds, else as keys.verify answers."""
89
+ hashed = fingerprint(bearer)
90
+ now = self.clock()
91
+ remembered = self.kept.get(hashed)
92
+ if remembered is not None and remembered.until > now:
93
+ return remembered.bearer
94
+ verified = await keys.verify(self.pool, bearer)
95
+ if verified is None:
96
+ self.kept.pop(hashed, None)
97
+ return None
98
+ until = now + self.for_s
99
+ if verified.key.expires_at is not None:
100
+ left = (verified.key.expires_at - datetime.now(UTC)).total_seconds()
101
+ until = min(until, now + max(left, 0.0))
102
+ self.kept[hashed] = Remembered(verified, until)
103
+ self._swept(now)
104
+ return verified
105
+
106
+ # Forgotten here before the door answers, and said to every other gateway: a signal that is
107
+ # away queues it, so the other gateways keep the key for the seconds and no longer.
108
+ def forget(self, *, fingerprint: str | None = None, subject: str | None = None) -> None:
109
+ """Forget a key, or a person's keys, on every gateway."""
110
+ forgotten = Forgotten(fingerprint=fingerprint, subject=subject)
111
+ self._forgotten(forgotten)
112
+ self.signal.publish(CHANNEL, forgotten.model_dump_json().encode())
113
+
114
+ def _forgotten(self, forgotten: Forgotten) -> None:
115
+ if forgotten.fingerprint is not None:
116
+ self.kept.pop(forgotten.fingerprint, None)
117
+ if forgotten.subject is not None:
118
+ for hashed in [h for h, kept in self.kept.items() if _of(kept, forgotten.subject)]:
119
+ del self.kept[hashed]
120
+
121
+ # Keys verified once and never again would stay: swept each period.
122
+ def _swept(self, now: float) -> None:
123
+ if now - self.swept_at < self.for_s:
124
+ return
125
+ self.swept_at = now
126
+ for hashed in [h for h, kept in self.kept.items() if kept.until <= now]:
127
+ del self.kept[hashed]
128
+
129
+ async def _listened(self) -> None:
130
+ while True:
131
+ try:
132
+ listening = await self.signal.subscribe(CHANNEL)
133
+ except NotAvailable:
134
+ await asyncio.sleep(RETRY_S)
135
+ continue
136
+ # What was revoked while the signal was away is unknown: nothing is trusted from before.
137
+ self.kept.clear()
138
+ try:
139
+ async for data in listening:
140
+ self._forgotten(Forgotten.model_validate_json(data))
141
+ finally:
142
+ listening.close()
143
+ with contextlib.suppress(TimeoutError):
144
+ await asyncio.wait_for(self.signal.connected(), RETRY_S)
145
+
146
+
147
+ def _of(kept: Remembered, subject: str) -> bool:
148
+ return kept.bearer.key.subject == subject
@@ -0,0 +1,86 @@
1
+ """Retention: calls past their org's days (policy.py); call records and dials past 24 months."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pinecall.domain.names import parse_env
6
+ from pinecall.domain.scope import Scope
7
+ from pinecall.postgres.pool import Pool, unbounded
8
+ from pinecall.process.recordings import Recordings
9
+ from pinecall.tenancy import erasure
10
+
11
+ # Every statement of the nightly run is unbounded: it reads and deletes across every org's years.
12
+ # Sealed calls only: a call still running is the caller's, not the calendar's. Oldest first, so a
13
+ # run cut short by its limit takes up where it stopped.
14
+ DUE = """
15
+ SELECT head.call, head.org, head.env, head.holder
16
+ FROM call_log_head head JOIN org_policy policy ON policy.org = head.org
17
+ WHERE head.call IS NOT NULL AND head.sealed AND head.started_at IS NOT NULL
18
+ AND policy.retention_days IS NOT NULL
19
+ AND head.started_at < %(now)s - policy.retention_days * 86400
20
+ ORDER BY head.started_at
21
+ LIMIT %(limit)s
22
+ """
23
+
24
+ # An erased phone call's record (erasure.py) and every dial (dial_policy.py) are kept for a
25
+ # carrier's traceback this long, then forgotten. A record of a call that never started goes by
26
+ # when it ended, or when it was erased.
27
+ RECORDS_KEPT_S = 730 * 86400
28
+
29
+ FORGET_RECORDS = """
30
+ DELETE FROM call_records
31
+ WHERE coalesce(started_at, ended_at, extract(epoch FROM erased_at)) < %(before)s
32
+ """
33
+
34
+ FORGET_DIALS = "DELETE FROM dials WHERE at < to_timestamp(%(before)s)"
35
+
36
+ # Who asked, in the erasure trail, for what the calendar erased.
37
+ RETENTION = "retention"
38
+
39
+ # One night's work at most; what is left over is the next night's.
40
+ A_RUN_ERASES = 5000
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Due:
45
+ """A sealed call past its org's days: whose it is, and which."""
46
+
47
+ scope: Scope
48
+ call: str
49
+
50
+
51
+ async def due(pool: Pool, now: float, *, limit: int = A_RUN_ERASES) -> list[Due]:
52
+ """The sealed calls whose org keeps fewer days than they have, oldest first."""
53
+ async with unbounded(pool) as connection:
54
+ rows = await (await connection.execute(DUE, {"now": now, "limit": limit})).fetchall()
55
+ return [
56
+ Due(
57
+ Scope(str(row["org"]), parse_env(str(row["env"])), str(row["holder"] or "")),
58
+ str(row["call"]),
59
+ )
60
+ for row in rows
61
+ ]
62
+
63
+
64
+ async def purge(
65
+ pool: Pool, recordings: Recordings, now: float, *, limit: int = A_RUN_ERASES
66
+ ) -> list[str]:
67
+ """Erase every call past its org's days, one erasure each; the calls erased."""
68
+ erased: list[str] = []
69
+ for call in await due(pool, now, limit=limit):
70
+ await erasure.call(pool, recordings, call.scope, call.call, by=RETENTION)
71
+ erased.append(call.call)
72
+ return erased
73
+
74
+
75
+ async def forget_records(pool: Pool, now: float) -> int:
76
+ """Forget the records of erased calls more than 24 months old; how many."""
77
+ async with unbounded(pool) as connection:
78
+ done = await connection.execute(FORGET_RECORDS, {"before": now - RECORDS_KEPT_S})
79
+ return done.rowcount
80
+
81
+
82
+ async def forget_dials(pool: Pool, now: float) -> int:
83
+ """Forget the dials placed or refused more than 24 months ago; how many."""
84
+ async with unbounded(pool) as connection:
85
+ done = await connection.execute(FORGET_DIALS, {"before": now - RECORDS_KEPT_S})
86
+ return done.rowcount