socialcli 0.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.

Potentially problematic release.


This version of socialcli might be problematic. Click here for more details.

Files changed (224) hide show
  1. socialcli-0.3.0.dist-info/METADATA +168 -0
  2. socialcli-0.3.0.dist-info/RECORD +224 -0
  3. socialcli-0.3.0.dist-info/WHEEL +4 -0
  4. socialcli-0.3.0.dist-info/entry_points.txt +3 -0
  5. socialcli-0.3.0.dist-info/licenses/LICENSE +21 -0
  6. socialctl/__init__.py +0 -0
  7. socialctl/__main__.py +6 -0
  8. socialctl/adapters/__init__.py +6 -0
  9. socialctl/adapters/base.py +68 -0
  10. socialctl/adapters/errores.py +56 -0
  11. socialctl/adapters/facebook.py +180 -0
  12. socialctl/adapters/instagram.py +442 -0
  13. socialctl/adapters/tiktok.py +535 -0
  14. socialctl/adapters/youtube.py +331 -0
  15. socialctl/agent_errors.py +176 -0
  16. socialctl/agent_guide.py +23 -0
  17. socialctl/approval_cli.py +27 -0
  18. socialctl/auth.py +139 -0
  19. socialctl/authflow.py +344 -0
  20. socialctl/brands.py +194 -0
  21. socialctl/capabilities.py +57 -0
  22. socialctl/capabilities.yml +5945 -0
  23. socialctl/cli.py +1766 -0
  24. socialctl/connection_service/__init__.py +0 -0
  25. socialctl/connection_service/__main__.py +33 -0
  26. socialctl/connection_service/app.py +496 -0
  27. socialctl/connection_service/assets/PLATFORM-LOGOS-LICENSE.txt +50 -0
  28. socialctl/connection_service/assets/connection.css +75 -0
  29. socialctl/connection_service/assets/facebook.webp +0 -0
  30. socialctl/connection_service/assets/instagram.svg +194 -0
  31. socialctl/connection_service/assets/socialcli-logo.png +0 -0
  32. socialctl/connection_service/assets/tiktok.png +0 -0
  33. socialctl/connection_service/assets/youtube.svg +1 -0
  34. socialctl/connection_service/landing.py +146 -0
  35. socialctl/connection_service/legacy.py +48 -0
  36. socialctl/connection_service/peer.py +13 -0
  37. socialctl/connection_service/providers.py +324 -0
  38. socialctl/connection_service/settings.py +76 -0
  39. socialctl/connection_service/store.py +124 -0
  40. socialctl/connections/__init__.py +0 -0
  41. socialctl/connections/account_cli.py +438 -0
  42. socialctl/connections/cli.py +379 -0
  43. socialctl/connections/client.py +126 -0
  44. socialctl/connections/credential_cli.py +224 -0
  45. socialctl/connections/credential_store.py +49 -0
  46. socialctl/connections/file_store.py +193 -0
  47. socialctl/connections/keychain.py +78 -0
  48. socialctl/connections/meta_user.py +81 -0
  49. socialctl/connections/rotation_cli.py +116 -0
  50. socialctl/connections/saas_client.py +91 -0
  51. socialctl/connections/temporary_media.py +62 -0
  52. socialctl/delivery_options.py +70 -0
  53. socialctl/engagement.py +200 -0
  54. socialctl/engagement_cli.py +50 -0
  55. socialctl/executor.py +135 -0
  56. socialctl/formatter.py +291 -0
  57. socialctl/hosted_media.py +152 -0
  58. socialctl/identity.py +200 -0
  59. socialctl/inbox.py +298 -0
  60. socialctl/inbox_cli.py +46 -0
  61. socialctl/inventory.py +155 -0
  62. socialctl/inventory_cli.py +171 -0
  63. socialctl/inventory_sources.py +101 -0
  64. socialctl/management/__init__.py +1 -0
  65. socialctl/management/batch_cli.py +115 -0
  66. socialctl/management/batches.py +325 -0
  67. socialctl/management/changes.py +485 -0
  68. socialctl/management/cli.py +227 -0
  69. socialctl/management/comments_cli.py +352 -0
  70. socialctl/management/community_batch_cli.py +63 -0
  71. socialctl/management/community_batches.py +294 -0
  72. socialctl/management/community_changes.py +370 -0
  73. socialctl/management/community_cli.py +142 -0
  74. socialctl/management/community_schema.py +119 -0
  75. socialctl/management/facebook_groups.py +1118 -0
  76. socialctl/management/groups_cli.py +274 -0
  77. socialctl/management/inbox_readers.py +87 -0
  78. socialctl/management/meta_changes.py +197 -0
  79. socialctl/management/meta_cli.py +342 -0
  80. socialctl/management/meta_client.py +631 -0
  81. socialctl/management/meta_comment_inbox.py +565 -0
  82. socialctl/management/meta_comments.py +587 -0
  83. socialctl/management/meta_errors.py +59 -0
  84. socialctl/management/meta_pagination.py +52 -0
  85. socialctl/management/meta_schema.py +128 -0
  86. socialctl/management/meta_webhooks.py +147 -0
  87. socialctl/management/metadata_parts.py +144 -0
  88. socialctl/management/owned_assets.py +85 -0
  89. socialctl/management/owned_changes.py +470 -0
  90. socialctl/management/owned_cli.py +128 -0
  91. socialctl/management/owned_reorder.py +62 -0
  92. socialctl/management/owned_schema.py +368 -0
  93. socialctl/management/reply_context.py +24 -0
  94. socialctl/management/reply_evidence.py +80 -0
  95. socialctl/management/resource_changes.py +407 -0
  96. socialctl/management/resource_cli.py +136 -0
  97. socialctl/management/snippet_readback.py +26 -0
  98. socialctl/management/stories.py +713 -0
  99. socialctl/management/stories_cli.py +172 -0
  100. socialctl/management/youtube.py +323 -0
  101. socialctl/management/youtube_community.py +326 -0
  102. socialctl/management/youtube_metadata.py +122 -0
  103. socialctl/management/youtube_owned.py +259 -0
  104. socialctl/management/youtube_resources.py +305 -0
  105. socialctl/media.py +269 -0
  106. socialctl/media_cli.py +183 -0
  107. socialctl/media_ingress.py +211 -0
  108. socialctl/media_preflight.py +114 -0
  109. socialctl/media_registry.py +301 -0
  110. socialctl/media_requirements.py +52 -0
  111. socialctl/metricas/__init__.py +6 -0
  112. socialctl/metricas/almacen.py +133 -0
  113. socialctl/metricas/analytics_changes.py +295 -0
  114. socialctl/metricas/analytics_cli.py +191 -0
  115. socialctl/metricas/analytics_client.py +304 -0
  116. socialctl/metricas/analytics_ingest.py +260 -0
  117. socialctl/metricas/analytics_schema.py +174 -0
  118. socialctl/metricas/base.py +147 -0
  119. socialctl/metricas/facebook.py +418 -0
  120. socialctl/metricas/insights.py +227 -0
  121. socialctl/metricas/instagram.py +133 -0
  122. socialctl/metricas/modelos.py +87 -0
  123. socialctl/metricas/piezas.py +134 -0
  124. socialctl/metricas/reach.py +94 -0
  125. socialctl/metricas/reporting_client.py +112 -0
  126. socialctl/metricas/resumen.py +251 -0
  127. socialctl/metricas/slugs.py +89 -0
  128. socialctl/metricas/tiktok.py +185 -0
  129. socialctl/metricas/youtube.py +403 -0
  130. socialctl/migration_cli.py +60 -0
  131. socialctl/migration_files.py +68 -0
  132. socialctl/models.py +150 -0
  133. socialctl/native_schedule/__init__.py +13 -0
  134. socialctl/native_schedule/api_delivery.py +15 -0
  135. socialctl/native_schedule/api_reconcile.py +40 -0
  136. socialctl/native_schedule/approval.py +160 -0
  137. socialctl/native_schedule/cadence.py +73 -0
  138. socialctl/native_schedule/cli.py +510 -0
  139. socialctl/native_schedule/dispatcher.py +120 -0
  140. socialctl/native_schedule/facebook.py +112 -0
  141. socialctl/native_schedule/facebook_reels.py +140 -0
  142. socialctl/native_schedule/first_comment.py +181 -0
  143. socialctl/native_schedule/handoff.py +193 -0
  144. socialctl/native_schedule/instagram_delivery.py +224 -0
  145. socialctl/native_schedule/instagram_runtime.py +66 -0
  146. socialctl/native_schedule/inventory.py +70 -0
  147. socialctl/native_schedule/migration.py +310 -0
  148. socialctl/native_schedule/models.py +103 -0
  149. socialctl/native_schedule/reconcile.py +83 -0
  150. socialctl/native_schedule/remote_data.py +177 -0
  151. socialctl/native_schedule/report.py +151 -0
  152. socialctl/native_schedule/store.py +560 -0
  153. socialctl/native_schedule/verification.py +32 -0
  154. socialctl/native_schedule/youtube.py +339 -0
  155. socialctl/operation_output.py +87 -0
  156. socialctl/plantillas/accounts.yml +33 -0
  157. socialctl/plantillas/brand.md +95 -0
  158. socialctl/plantillas/estrategia.md +64 -0
  159. socialctl/postfile.py +240 -0
  160. socialctl/preflight.py +73 -0
  161. socialctl/publication_policy.py +34 -0
  162. socialctl/publication_status.py +75 -0
  163. socialctl/publication_steps.py +398 -0
  164. socialctl/publish_output.py +96 -0
  165. socialctl/publisher.py +445 -0
  166. socialctl/queue_migration.py +302 -0
  167. socialctl/read_budget.py +224 -0
  168. socialctl/read_reports.py +91 -0
  169. socialctl/rutas.py +103 -0
  170. socialctl/saas/__init__.py +1 -0
  171. socialctl/saas/access.py +75 -0
  172. socialctl/saas/auth.py +213 -0
  173. socialctl/saas/backup.py +124 -0
  174. socialctl/saas/billing.py +327 -0
  175. socialctl/saas/billing_enrollment.py +1237 -0
  176. socialctl/saas/billing_enrollment_schema.py +519 -0
  177. socialctl/saas/billing_inventory.py +775 -0
  178. socialctl/saas/billing_reconcile.py +127 -0
  179. socialctl/saas/billing_routes.py +27 -0
  180. socialctl/saas/budget.py +66 -0
  181. socialctl/saas/connections.py +374 -0
  182. socialctl/saas/consent.py +52 -0
  183. socialctl/saas/lifecycle.py +341 -0
  184. socialctl/saas/mail.py +89 -0
  185. socialctl/saas/maintenance.py +68 -0
  186. socialctl/saas/meta_user.py +298 -0
  187. socialctl/saas/meta_webhooks.py +358 -0
  188. socialctl/saas/migration.py +107 -0
  189. socialctl/saas/models.py +8 -0
  190. socialctl/saas/operations.py +102 -0
  191. socialctl/saas/pages.py +126 -0
  192. socialctl/saas/pairing.py +158 -0
  193. socialctl/saas/permissions.py +57 -0
  194. socialctl/saas/reminders.py +128 -0
  195. socialctl/saas/renewal.py +74 -0
  196. socialctl/saas/restore_guard.py +229 -0
  197. socialctl/saas/revocation.py +97 -0
  198. socialctl/saas/rotation.py +114 -0
  199. socialctl/saas/routes.py +221 -0
  200. socialctl/saas/settings.py +34 -0
  201. socialctl/saas/store.py +106 -0
  202. socialctl/saas/temporary_media.py +245 -0
  203. socialctl/saas/webhooks.py +76 -0
  204. socialctl/schedule_remote.py +262 -0
  205. socialctl/schedule_sqlite.py +44 -0
  206. socialctl/scheduler.py +581 -0
  207. socialctl/scheduler_launchd.py +319 -0
  208. socialctl/scheduler_systemd.py +167 -0
  209. socialctl/source_links.py +119 -0
  210. socialctl/ui/__init__.py +1 -0
  211. socialctl/ui/assets/agent-manual.md +850 -0
  212. socialctl/ui/assets/agents.md +27 -0
  213. socialctl/ui/assets/components.css +20 -0
  214. socialctl/ui/assets/connection-layout.css +53 -0
  215. socialctl/ui/assets/otp.template.html +10 -0
  216. socialctl/ui/assets/tokens.css +1 -0
  217. socialctl/ui/assets/trial.template.html +11 -0
  218. socialctl/ui/assets/web-layout.css +38 -0
  219. socialctl/ui/email/otp.html +10 -0
  220. socialctl/ui/email/trial.html +11 -0
  221. socialctl/upload_consent.py +13 -0
  222. socialctl/workspace.py +8 -0
  223. socialctl/youtube_data.py +471 -0
  224. socialctl/youtube_data_service.py +225 -0
@@ -0,0 +1,775 @@
1
+ """Constructor-free, offline schema10 billing evidence. Never enrollment authority.
2
+
3
+ Only source-defined schemas are accepted. No target SQL is executed as DDL, and
4
+ no service/store constructor, provider, migration or cleanup is used. SQLite may
5
+ update its WAL read-lock bookkeeping; database rows and schema remain read-only.
6
+ Protected operational custody is required; identity checks cannot defeat a
7
+ privileged host adversary. Keys and decrypted payloads never enter the report.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from contextlib import closing
12
+ import hashlib
13
+ import json
14
+ import math
15
+ import os
16
+ from pathlib import Path
17
+ import re
18
+ import sqlite3
19
+ import stat
20
+ import struct
21
+ import sys
22
+
23
+ from cryptography.fernet import Fernet, MultiFernet
24
+
25
+
26
+ class InventoryError(ValueError):
27
+ """Fixed sanitized diagnostics only."""
28
+
29
+
30
+ _ERROR = 'Inventory inspection failed.'
31
+ # Exact source-derived sqlite_master structure from Store and SaaSStore schema10.
32
+ # Deliberately static: never instantiate stores or derive a whitelist from input.
33
+ _BASE_SCHEMA = (('index',
34
+ 'saas_google_revocations_due',
35
+ 'saas_google_revocations',
36
+ 'CREATE INDEX saas_google_revocations_due ON saas_google_revocations(state,next_attempt,lease_until)'),
37
+ ('index',
38
+ 'saas_media_allocations_owner',
39
+ 'saas_media_allocations',
40
+ 'CREATE INDEX saas_media_allocations_owner ON saas_media_allocations(user_id,expires)'),
41
+ ('index',
42
+ 'saas_record_emails_hash',
43
+ 'saas_record_emails',
44
+ 'CREATE INDEX saas_record_emails_hash ON saas_record_emails(email_hash,kind,id)'),
45
+ ('index',
46
+ 'saas_record_links_grant',
47
+ 'saas_record_links',
48
+ 'CREATE INDEX saas_record_links_grant ON saas_record_links(grant_id,kind,id)'),
49
+ ('index',
50
+ 'saas_record_links_installation',
51
+ 'saas_record_links',
52
+ 'CREATE INDEX saas_record_links_installation ON saas_record_links(installation_id,kind,id)'),
53
+ ('index',
54
+ 'saas_record_owners_user',
55
+ 'saas_record_owners',
56
+ 'CREATE INDEX saas_record_owners_user ON saas_record_owners(user_id,kind,id)'),
57
+ ('index',
58
+ 'saas_reminders_due',
59
+ 'saas_reminders',
60
+ 'CREATE INDEX saas_reminders_due ON saas_reminders(state,next_attempt,trial_end)'),
61
+ ('index', 'sqlite_autoindex_rates_1', 'rates', None),
62
+ ('index', 'sqlite_autoindex_records_1', 'records', None),
63
+ ('index', 'sqlite_autoindex_saas_billing_1', 'saas_billing', None),
64
+ ('index', 'sqlite_autoindex_saas_billing_2', 'saas_billing', None),
65
+ ('index', 'sqlite_autoindex_saas_billing_3', 'saas_billing', None),
66
+ ('index', 'sqlite_autoindex_saas_billing_events_1', 'saas_billing_events', None),
67
+ ('index', 'sqlite_autoindex_saas_brands_1', 'saas_brands', None),
68
+ ('index', 'sqlite_autoindex_saas_brands_2', 'saas_brands', None),
69
+ ('index', 'sqlite_autoindex_saas_deletion_tombstones_1', 'saas_deletion_tombstones', None),
70
+ ('index', 'sqlite_autoindex_saas_deletions_1', 'saas_deletions', None),
71
+ ('index', 'sqlite_autoindex_saas_entitlements_1', 'saas_entitlements', None),
72
+ ('index', 'sqlite_autoindex_saas_google_revocations_1', 'saas_google_revocations', None),
73
+ ('index', 'sqlite_autoindex_saas_media_allocations_1', 'saas_media_allocations', None),
74
+ ('index', 'sqlite_autoindex_saas_rates_1', 'saas_rates', None),
75
+ ('index', 'sqlite_autoindex_saas_record_emails_1', 'saas_record_emails', None),
76
+ ('index', 'sqlite_autoindex_saas_record_links_1', 'saas_record_links', None),
77
+ ('index', 'sqlite_autoindex_saas_record_owners_1', 'saas_record_owners', None),
78
+ ('index', 'sqlite_autoindex_saas_reminders_1', 'saas_reminders', None),
79
+ ('index', 'sqlite_autoindex_saas_reminders_2', 'saas_reminders', None),
80
+ ('index', 'sqlite_autoindex_saas_slots_1', 'saas_slots', None),
81
+ ('index', 'sqlite_autoindex_saas_slots_2', 'saas_slots', None),
82
+ ('index', 'sqlite_autoindex_saas_trial_markers_1', 'saas_trial_markers', None),
83
+ ('index', 'sqlite_autoindex_saas_users_1', 'saas_users', None),
84
+ ('table',
85
+ 'rates',
86
+ 'rates',
87
+ 'CREATE TABLE rates (identity TEXT PRIMARY KEY, bucket INTEGER NOT NULL, count INTEGER NOT NULL)'),
88
+ ('table',
89
+ 'records',
90
+ 'records',
91
+ 'CREATE TABLE records (kind TEXT NOT NULL, id TEXT NOT NULL, expires REAL NOT NULL, credential_hash TEXT NOT '
92
+ 'NULL, payload BLOB NOT NULL, PRIMARY KEY(kind,id))'),
93
+ ('table',
94
+ 'saas_billing',
95
+ 'saas_billing',
96
+ 'CREATE TABLE saas_billing (user_id TEXT PRIMARY KEY,customer_id TEXT UNIQUE,subscription_id TEXT '
97
+ 'UNIQUE,customer_key TEXT NOT NULL,customer_started REAL NOT NULL,checkout_id TEXT,attempt TEXT,attempt_started '
98
+ 'REAL,lease TEXT,lease_until REAL NOT NULL DEFAULT 0,sync_due REAL NOT NULL DEFAULT 0,cancel_at_period_end '
99
+ "INTEGER NOT NULL DEFAULT 0,review TEXT NOT NULL DEFAULT '')"),
100
+ ('table',
101
+ 'saas_billing_events',
102
+ 'saas_billing_events',
103
+ 'CREATE TABLE saas_billing_events (account_id TEXT NOT NULL,mode TEXT NOT NULL,event_id TEXT NOT NULL,body_hash '
104
+ "TEXT NOT NULL,customer_id TEXT,type TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',received REAL NOT "
105
+ "NULL,next_attempt REAL NOT NULL DEFAULT 0, semantic_hash TEXT NOT NULL DEFAULT '',PRIMARY "
106
+ 'KEY(account_id,mode,event_id))'),
107
+ ('table',
108
+ 'saas_brands',
109
+ 'saas_brands',
110
+ 'CREATE TABLE saas_brands (user_id TEXT PRIMARY KEY,id TEXT NOT NULL UNIQUE,label TEXT NOT NULL)'),
111
+ ('table',
112
+ 'saas_deletion_tombstones',
113
+ 'saas_deletion_tombstones',
114
+ 'CREATE TABLE saas_deletion_tombstones (kind TEXT NOT NULL,id TEXT NOT NULL,requested REAL NOT NULL,PRIMARY '
115
+ 'KEY(kind,id))'),
116
+ ('table',
117
+ 'saas_deletions',
118
+ 'saas_deletions',
119
+ 'CREATE TABLE saas_deletions (user_id TEXT PRIMARY KEY,requested REAL NOT NULL,state TEXT NOT NULL DEFAULT '
120
+ "'pending',billing_done INTEGER NOT NULL DEFAULT 0,next_attempt REAL NOT NULL DEFAULT 0,lease TEXT,lease_until "
121
+ 'REAL NOT NULL DEFAULT 0)'),
122
+ ('table',
123
+ 'saas_entitlements',
124
+ 'saas_entitlements',
125
+ 'CREATE TABLE saas_entitlements (user_id TEXT PRIMARY KEY,trial_start REAL,trial_end REAL,subscription_status '
126
+ "TEXT NOT NULL DEFAULT '',paid_until REAL NOT NULL DEFAULT 0)"),
127
+ ('table',
128
+ 'saas_google_revocations',
129
+ 'saas_google_revocations',
130
+ 'CREATE TABLE saas_google_revocations (id TEXT PRIMARY KEY,user_id TEXT NOT NULL,requested REAL NOT NULL,state '
131
+ "TEXT NOT NULL DEFAULT 'pending',next_attempt REAL NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,lease "
132
+ 'TEXT,lease_until REAL NOT NULL DEFAULT 0)'),
133
+ ('table',
134
+ 'saas_media_allocations',
135
+ 'saas_media_allocations',
136
+ 'CREATE TABLE saas_media_allocations (id TEXT PRIMARY KEY,user_id TEXT NOT NULL,size INTEGER NOT NULL,expires '
137
+ 'REAL NOT NULL)'),
138
+ ('table',
139
+ 'saas_rates',
140
+ 'saas_rates',
141
+ 'CREATE TABLE saas_rates (key TEXT PRIMARY KEY, bucket INTEGER NOT NULL, count INTEGER NOT NULL, expires REAL '
142
+ 'NOT NULL DEFAULT 0)'),
143
+ ('table',
144
+ 'saas_record_emails',
145
+ 'saas_record_emails',
146
+ 'CREATE TABLE saas_record_emails (kind TEXT NOT NULL,id TEXT NOT NULL,email_hash TEXT NOT NULL,PRIMARY '
147
+ 'KEY(kind,id,email_hash))'),
148
+ ('table',
149
+ 'saas_record_links',
150
+ 'saas_record_links',
151
+ 'CREATE TABLE saas_record_links (kind TEXT NOT NULL,id TEXT NOT NULL,installation_id TEXT,grant_id TEXT,PRIMARY '
152
+ 'KEY(kind,id))'),
153
+ ('table',
154
+ 'saas_record_owners',
155
+ 'saas_record_owners',
156
+ 'CREATE TABLE saas_record_owners (user_id TEXT NOT NULL, kind TEXT NOT NULL, id TEXT NOT NULL, PRIMARY '
157
+ 'KEY(kind,id))'),
158
+ ('table',
159
+ 'saas_reminders',
160
+ 'saas_reminders',
161
+ 'CREATE TABLE saas_reminders (id TEXT PRIMARY KEY,user_id TEXT NOT NULL,trial_end REAL NOT NULL,milestone '
162
+ "INTEGER NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,state TEXT NOT NULL DEFAULT 'pending',next_attempt REAL "
163
+ 'NOT NULL DEFAULT 0,lease TEXT,lease_until REAL NOT NULL DEFAULT 0,UNIQUE(user_id,trial_end,milestone))'),
164
+ ('table',
165
+ 'saas_schema',
166
+ 'saas_schema',
167
+ 'CREATE TABLE saas_schema (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'),
168
+ ('table',
169
+ 'saas_slots',
170
+ 'saas_slots',
171
+ 'CREATE TABLE saas_slots (user_id TEXT NOT NULL,platform TEXT NOT NULL,grant_id TEXT NOT NULL UNIQUE,PRIMARY '
172
+ 'KEY(user_id,platform))'),
173
+ ('table',
174
+ 'saas_trial_markers',
175
+ 'saas_trial_markers',
176
+ 'CREATE TABLE saas_trial_markers (marker TEXT PRIMARY KEY,trial_start REAL NOT NULL,trial_end REAL NOT '
177
+ 'NULL,retain_until REAL NOT NULL)'),
178
+ ('table',
179
+ 'saas_users',
180
+ 'saas_users',
181
+ 'CREATE TABLE saas_users (id TEXT PRIMARY KEY, created REAL NOT NULL, suspended INTEGER NOT NULL DEFAULT 0, '
182
+ 'deleting INTEGER NOT NULL DEFAULT 0, session_version INTEGER NOT NULL DEFAULT 0)'),
183
+ ('trigger',
184
+ 'records_links_delete',
185
+ 'records',
186
+ 'CREATE TRIGGER records_links_delete AFTER DELETE ON records BEGIN DELETE FROM saas_record_links WHERE '
187
+ 'kind=OLD.kind AND id=OLD.id; DELETE FROM saas_record_emails WHERE kind=OLD.kind AND id=OLD.id; END'),
188
+ ('trigger',
189
+ 'records_meta_webhook_grant_delete',
190
+ 'records',
191
+ 'CREATE TRIGGER records_meta_webhook_grant_delete AFTER DELETE ON records\n'
192
+ " WHEN OLD.kind='saas-grant' BEGIN\n"
193
+ " DELETE FROM records WHERE kind IN ('saas-meta-webhook-binding','saas-meta-webhook-event')\n"
194
+ ' AND (kind,id) IN (SELECT kind,id FROM saas_record_links WHERE grant_id=OLD.id); END'),
195
+ ('trigger',
196
+ 'records_owner_delete',
197
+ 'records',
198
+ 'CREATE TRIGGER records_owner_delete AFTER DELETE ON records BEGIN DELETE FROM saas_record_owners WHERE '
199
+ 'kind=OLD.kind AND id=OLD.id; END'))
200
+
201
+ # Optional source-defined structures: LegacyPolicy and restore_guard respectively.
202
+ _LEGACY_SCHEMA = (
203
+ ('table', 'legacy_cutoff', 'legacy_cutoff', 'CREATE TABLE legacy_cutoff (singleton INTEGER PRIMARY KEY CHECK(singleton=1),closed_at REAL NOT NULL,drain_until REAL NOT NULL)'),
204
+ ('table', 'legacy_allowance', 'legacy_allowance', 'CREATE TABLE legacy_allowance (kind TEXT NOT NULL,id TEXT NOT NULL,expires REAL NOT NULL,activated INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(kind,id))'),
205
+ ('index', 'sqlite_autoindex_legacy_allowance_1', 'legacy_allowance', None),
206
+ )
207
+ _RESTORE_SCHEMA = ('table', 'saas_restore_guard', 'saas_restore_guard', 'CREATE TABLE saas_restore_guard (singleton INTEGER PRIMARY KEY CHECK(singleton=1),state TEXT NOT NULL,prepared REAL NOT NULL,snapshot_created REAL NOT NULL)')
208
+ # Source commits 14da67a and 7f13e7c establish these two exact schema10 variants.
209
+ # The receiver introduced both objects without a schema-version bump. Neither
210
+ # half-pairs nor other extensions are supported, and inspection creates nothing.
211
+ _RECEIVER_OBJECTS = frozenset({'records_meta_webhook_grant_delete', 'saas_record_links_grant'})
212
+ # Independent source-fixture metadata roots, never derived from target input.
213
+ _SOURCE_METADATA_ROOTS = {'legacy_allowance': '59ab53a6eae082ecc0ccedb25c6f8b7bc5127b7651df56a96a7326f74c54adaa',
214
+ 'legacy_cutoff': '566877cf13a088cbec1ed55433a172132c6e9771a55d3db216479af6021f9bab',
215
+ 'rates': '86e994b9c525b313fbbfdf43e4c690b39cf611d3cc1d1b662017f895a4adbdad',
216
+ 'records': '77f2bf8b9c4dd1b5e9e5f825f36e84eea488c24880c9e7b9a94629f13bf9e48d',
217
+ 'saas_billing': '7919da862f9ce0c1f37c0ddd5e725a21229b46e8848802b43e14140571c9c96e',
218
+ 'saas_billing_events': '70e20cc4f8867aa7db2b459c34e19e6e396a5013fe7bfdb8167e5e0a3f06f1f6',
219
+ 'saas_brands': '722bccd8a0960da71288accbf54f6f62bf79d089f8d61b53f29926432b191c16',
220
+ 'saas_deletion_tombstones': '5bc3e8081d4c81809d04f376a47b1d2b3aa19e75a454129bf7ea310362bf8a92',
221
+ 'saas_deletions': '50941802f8a0ef133123e43a18b4945973a912d03a772acda2f3e3f77fb9f504',
222
+ 'saas_entitlements': '249e774d9d5c2d216bf57ddb7ac6bc45b4edde539f45bf0f53c44374c4584548',
223
+ 'saas_google_revocations': '1425730c8fd98e245e04db166d31f0d0ec8cff3e3ed587360eaeebbad184cca2',
224
+ 'saas_media_allocations': '07132fb53b9485c69462006c4c31b07ec8ee7e5651d885582d80f2c54fb43cac',
225
+ 'saas_rates': '1008c36c024512f5ac44f8cde89a2baa2b4f170b2802fa286af786ae18b9e521',
226
+ 'saas_record_emails': 'e31d5faa19c29e5b27eb6fc55d3e92c5000f1acff08d6237723fafaa1a4a3155',
227
+ 'saas_record_links': 'e03bc340dfcc599b41ce1c1ceba3818506893cad19c1c17ec2fbd47451723cc5',
228
+ 'saas_record_owners': '46016699fa9a7a3ec60d62c1af6997c90913ceba47691a9de92d16c765db5655',
229
+ 'saas_reminders': '49cebb5a3429f9f3fd43e88eacf35f389c2bac4af7ee536c4b0214fd201b4c42',
230
+ 'saas_restore_guard': 'a3e9ff7182c33d09571106aedf24dca9a186fc7ceb4bf7580e184f75cfe4ea40',
231
+ 'saas_schema': 'e9e0528377a1836dccfd4c52dc5b9901ef174c27b6e486ce1a6edeb82f067bb0',
232
+ 'saas_slots': '2bc3fc1f82d86f42d4af5cae6e2960854f7a135245f9b4a01e54c679ec75ce50',
233
+ 'saas_trial_markers': '7c4f22330521358afe5658bbf7491117820a8864b9984357d5d13717f6e0de4e',
234
+ 'saas_users': '38ef361bdaa1c8a2c9cb788fd5a0b4337ee919376cb22efdefeee9b88cb70796'}
235
+ _DEPLOYED_LINKS_METADATA_ROOT = '52911f911ef345ca7896f556eb319a00bc69ed8f2dd16824df331b412621405d'
236
+ _KNOWN_KINDS = frozenset({
237
+ 'authorization', 'connection', 'google-revocation', 'saas-billing-environment',
238
+ 'saas-checkout-intent', 'saas-stripe-event', 'saas-user', 'saas-login',
239
+ 'saas-session', 'saas-pair', 'saas-installation', 'saas-capability',
240
+ 'saas-candidate', 'saas-grant', 'saas-consent', 'saas-delete',
241
+ 'saas-meta-operation', 'saas-meta-target', 'saas-meta-webhook-binding',
242
+ 'saas-meta-webhook-event', 'saas-migration', 'saas-privacy', 'saas-rotation',
243
+ })
244
+ _STATES = frozenset({'pending', 'done', 'ignored', 'failed', 'uncertain', 'sending',
245
+ 'sent', 'rejected', 'review', 'processing', 'canceled'})
246
+ _SUBSCRIPTIONS = frozenset({'', 'active', 'canceled', 'trialing', 'past_due',
247
+ 'unpaid', 'incomplete', 'incomplete_expired', 'paused'})
248
+
249
+
250
+ def _pack(value: bytes) -> bytes:
251
+ return struct.pack('>Q', len(value)) + value
252
+
253
+
254
+ def _typed(value) -> bytes:
255
+ """Lossless SQL value encoding; integer/real, signed zero, text/blob differ."""
256
+ if value is None:
257
+ return b'n'
258
+ if type(value) is int:
259
+ return b'i' + _pack(str(value).encode('ascii'))
260
+ if type(value) is float:
261
+ if not math.isfinite(value):
262
+ raise ValueError()
263
+ return b'r' + struct.pack('>d', value)
264
+ if type(value) is str:
265
+ return b't' + _pack(value.encode('utf-8'))
266
+ if type(value) is bytes:
267
+ return b'b' + _pack(value)
268
+ raise ValueError()
269
+
270
+
271
+ def _row(row) -> bytes:
272
+ return _pack(b''.join(_pack(_typed(value)) for value in row))
273
+
274
+
275
+ def _rows_digest(rows) -> str:
276
+ digest = hashlib.sha256(b'socialcli-inventory-rows-v1\0')
277
+ # Sorting retains duplicates, unlike a map keyed by nullable SQL primary keys.
278
+ for encoded in sorted(_row(row) for row in rows):
279
+ digest.update(encoded)
280
+ return digest.hexdigest()
281
+
282
+
283
+ def _sql_shape(sql):
284
+ if sql is None:
285
+ return None
286
+ # Compare lexical tokens, never concatenate their spelling: identity TEXT
287
+ # must remain distinct from the single identifier identityTEXT. Quoted
288
+ # literals/identifiers retain their exact bytes and punctuation stays ordered.
289
+ return tuple(re.findall(r"'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|[A-Za-z_][A-Za-z0-9_]*|[0-9]+|>=|<=|<>|!=|==|\|\||[^\s]", sql))
290
+
291
+
292
+ def _schema_variant(schema):
293
+ present = {row[1] for row in schema} & _RECEIVER_OBJECTS
294
+ if not present:
295
+ return 'schema10_deployed_rc15'
296
+ if present == _RECEIVER_OBJECTS:
297
+ return 'schema10_candidate_receiver'
298
+ raise ValueError()
299
+
300
+
301
+ def _metadata_rows(db, table):
302
+ # Names are accepted source identifiers before interpolation. These rows
303
+ # cover column ordinals/types/defaults/nullability/PK/hidden status, and all
304
+ # index names/uniqueness/origin/partial flags, key order/collations/direction.
305
+ if table not in _SOURCE_METADATA_ROOTS:
306
+ raise ValueError()
307
+ rows = [('column', *tuple(row)) for row in db.execute(f'PRAGMA table_xinfo("{table}")')]
308
+ for _, name, unique, origin, partial in db.execute(f'PRAGMA index_list("{table}")'):
309
+ if not re.fullmatch(r'[A-Za-z0-9_]+', name):
310
+ raise ValueError()
311
+ rows.append(('index', name, unique, origin, partial))
312
+ rows.extend(('index_column', name, *tuple(row))
313
+ for row in db.execute(f'PRAGMA index_xinfo("{name}")'))
314
+ return rows
315
+
316
+
317
+ def _validate_schema_metadata(db, tables, variant='schema10_candidate_receiver'):
318
+ for table in tables:
319
+ expected = _SOURCE_METADATA_ROOTS[table]
320
+ if variant == 'schema10_deployed_rc15' and table == 'saas_record_links':
321
+ expected = _DEPLOYED_LINKS_METADATA_ROOT
322
+ if _rows_digest(_metadata_rows(db, table)) != expected:
323
+ raise ValueError()
324
+
325
+
326
+ def _private_path(path) -> Path:
327
+ path = Path(path)
328
+ text = str(path)
329
+ if (not path.is_absolute() or '..' in path.parts or any(c in text for c in '?#')
330
+ or any(ord(c) < 32 for c in text)):
331
+ raise ValueError()
332
+ # Do not resolve symlinks into apparently safe paths. Parent directories need
333
+ # not be secret, but untrusted writers cannot control any non-sticky ancestor.
334
+ for parent in reversed(path.parents):
335
+ info = parent.lstat()
336
+ if not stat.S_ISDIR(info.st_mode):
337
+ raise ValueError()
338
+ writable = info.st_mode & 0o022
339
+ trusted_sticky = info.st_uid == 0 and bool(info.st_mode & stat.S_ISVTX)
340
+ if writable and not trusted_sticky:
341
+ raise ValueError()
342
+ parent = path.parent.stat()
343
+ if (parent.st_uid != os.geteuid() or stat.S_IMODE(parent.st_mode) & 0o077
344
+ or stat.S_IMODE(parent.st_mode) & 0o700 != 0o700):
345
+ raise ValueError()
346
+ return path
347
+
348
+
349
+ def _identity(info) -> dict:
350
+ return {'device': info.st_dev, 'inode': info.st_ino, 'owner': info.st_uid,
351
+ 'group': info.st_gid, 'mode': stat.S_IMODE(info.st_mode), 'links': info.st_nlink}
352
+
353
+
354
+ def _file_info(path):
355
+ _private_path(path)
356
+ info = path.lstat()
357
+ if (not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600
358
+ or info.st_uid != os.geteuid() or info.st_nlink != 1):
359
+ raise ValueError()
360
+ return info
361
+
362
+
363
+ def _assert_identity(path, expected, fd):
364
+ if _identity(_file_info(path)) != expected or _identity(os.fstat(fd)) != expected:
365
+ raise ValueError()
366
+
367
+
368
+ def _schema(db):
369
+ schema = [tuple(row) for row in db.execute('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name')]
370
+ variant = _schema_variant(schema)
371
+ expected = list(_BASE_SCHEMA)
372
+ if variant == 'schema10_deployed_rc15':
373
+ expected = [row for row in expected if row[1] not in _RECEIVER_OBJECTS]
374
+ names = {row[1] for row in schema}
375
+ if names & {'legacy_cutoff', 'legacy_allowance'}:
376
+ expected.extend(_LEGACY_SCHEMA)
377
+ if 'saas_restore_guard' in names:
378
+ expected.append(_RESTORE_SCHEMA)
379
+ def shape(rows):
380
+ return sorted((kind, name, table, _sql_shape(sql)) for kind, name, table, sql in rows)
381
+ if shape(schema) != shape(expected):
382
+ raise ValueError()
383
+ _validate_schema_metadata(db, [row[1] for row in schema if row[0] == 'table'], variant)
384
+ if (db.execute('PRAGMA user_version').fetchone()[0] != 0
385
+ or db.execute('PRAGMA application_id').fetchone()[0] != 0
386
+ or db.execute('SELECT version FROM saas_schema ORDER BY version').fetchall()
387
+ != [(i,) for i in range(1, 11)]):
388
+ raise ValueError()
389
+ if 'saas_restore_guard' in names:
390
+ guard = db.execute('SELECT singleton,state,prepared,snapshot_created FROM saas_restore_guard').fetchall()
391
+ if len(guard) != 1 or guard[0][0] != 1 or guard[0][1] != 'ready':
392
+ raise ValueError()
393
+ if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
394
+ raise ValueError()
395
+ return schema
396
+
397
+
398
+ def _logical_state(db, schema, *, exclude_binding=False):
399
+ """Pin every table, column, duplicate and ciphertext in this transaction."""
400
+ tables = {}
401
+ schema_metadata = {}
402
+ for name in sorted(row[1] for row in schema if row[0] == 'table'):
403
+ columns = sorted(tuple(row) for row in db.execute(f'PRAGMA table_xinfo("{name}")'))
404
+ # The source allowlist fixes names before any interpolated identifier.
405
+ names = sorted(row[1] for row in columns)
406
+ selection = ','.join('"'+name+'"' for name in names)
407
+ query = f'SELECT {selection} FROM "{name}"'
408
+ parameters = ()
409
+ if exclude_binding and name == 'records':
410
+ query += ' WHERE NOT (kind=? AND id=?)'
411
+ parameters = ('saas-billing-environment', 'active')
412
+ rows = db.execute(query, parameters).fetchall()
413
+ tables[name] = {'columns': names, 'rows': len(rows), 'digest': _rows_digest(rows)}
414
+ schema_metadata[name] = {'columns': columns, 'indexes': []}
415
+ for index in db.execute(f'PRAGMA index_list("{name}")').fetchall():
416
+ schema_metadata[name]['indexes'].append((tuple(index),
417
+ [tuple(column) for column in db.execute(f'PRAGMA index_xinfo("{index[1]}")')]))
418
+ schema_root = _schema_root(schema, schema_metadata, db.execute('PRAGMA encoding').fetchone()[0])
419
+ return tables, schema_root, _data_root(tables)
420
+
421
+
422
+ def _schema_root(schema, metadata, encoding):
423
+ schema_parts = list(schema)
424
+ for name, value in sorted(metadata.items()):
425
+ schema_parts.extend(('column', name, *column) for column in value['columns'])
426
+ for index, columns in value['indexes']:
427
+ schema_parts.append(('index_metadata', name, *index))
428
+ schema_parts.extend(('index_column', index[1], *column) for column in columns)
429
+ schema_parts.append(('encoding', encoding))
430
+ return _rows_digest(schema_parts)
431
+
432
+
433
+ def _data_root(tables):
434
+ return _rows_digest((name, json.dumps(value['columns'], separators=(',', ':')),
435
+ value['rows'], value['digest']) for name, value in tables.items())
436
+
437
+
438
+ def _state_root(schema_root, data_root, identity):
439
+ """Shared inventory/enrollment state-root encoder; preserve report bytes."""
440
+ return _rows_digest([(schema_root, data_root, json.dumps(identity, sort_keys=True))])
441
+
442
+
443
+ def _authenticate(db, keys, *, json_loader=json.loads):
444
+ cipher = MultiFernet([Fernet(key) for key in keys])
445
+ records = {}
446
+ for kind, record_id, expires, credential_hash, ciphertext in db.execute('SELECT kind,id,expires,credential_hash,payload FROM records ORDER BY kind,id'):
447
+ envelope = json_loader(cipher.decrypt(ciphertext))
448
+ if (not isinstance(envelope, dict) or envelope['kind'] != kind or envelope['id'] != record_id
449
+ or envelope['expires'] != expires or envelope['credential_hash'] != credential_hash
450
+ or not isinstance(envelope['payload'], dict)):
451
+ raise ValueError()
452
+ records[(kind, record_id)] = envelope['payload']
453
+ return records
454
+
455
+
456
+ def _dict_rows(db, table):
457
+ cursor = db.execute('SELECT * FROM '+table)
458
+ names = [column[0] for column in cursor.description]
459
+ return [dict(zip(names, row)) for row in cursor]
460
+
461
+
462
+ def _state(value, allowed=_STATES):
463
+ return value if value in allowed else 'unknown'
464
+
465
+
466
+ def _jobs(db, table):
467
+ rows = _dict_rows(db, table)
468
+ counts = {}
469
+ for row in rows:
470
+ state = _state(row['state'])
471
+ counts[state] = counts.get(state, 0) + 1
472
+ return {'rows': len(rows), 'states': dict(sorted(counts.items())),
473
+ 'leased': sum(bool(row['lease']) or row['lease_until'] != 0 for row in rows)}
474
+
475
+
476
+ _REPORT_REFERENCES = frozenset({'user_id', 'account_id', 'customer_id',
477
+ 'subscription_id', 'checkout_id', 'attempt', 'kind', 'id'})
478
+ _REPORT_NUMBERS = frozenset({'customer_started', 'attempt_started', 'lease_until',
479
+ 'sync_due', 'trial_start', 'trial_end', 'paid_until'})
480
+ _REPORT_NULLABLE_NUMBERS = frozenset({'attempt_started', 'trial_start', 'trial_end'})
481
+ _REPORT_ENUMS = {
482
+ 'mode': frozenset({'test', 'live'}),
483
+ 'state': _STATES | {'unknown', 'absent', 'present', 'ready'},
484
+ 'subscription_status': _SUBSCRIPTIONS | {'unknown'},
485
+ 'provenance': frozenset({'NOT_ESTABLISHED'}),
486
+ 'owner_reviewer_policy': frozenset({'NOT_ESTABLISHED'}),
487
+ 'customer_outcome': frozenset({'pointer_present', 'uncertain'}),
488
+ 'checkout_outcome': frozenset({'pointer_present', 'uncertain', 'absent'}),
489
+ 'outcome': frozenset({'uncertain', 'terminal'}),
490
+ 'classification': frozenset({'review', 'in_flight', 'uncertain'}),
491
+ 'local_discrepancies': frozenset({'event_id', 'account_id', 'mode', 'customer_id', 'owner'}),
492
+ }
493
+
494
+
495
+ def _report_identifier(value):
496
+ if (not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9_:-]{1,512}', value)
497
+ or value.startswith(('sk_', 'rk_', 'whsec_'))):
498
+ raise ValueError()
499
+
500
+
501
+ def _check_report_contract(value, field=None):
502
+ """Validate every exported scalar before a private artifact can be opened.
503
+
504
+ SQLite declared affinities do not constrain stored value types. All dynamic
505
+ strings must be explicit identifiers or fixed enums; numeric observations
506
+ must be finite numbers, and counts/booleans never accept arbitrary strings.
507
+ The two dynamic maps also validate their keys instead of only their values.
508
+ """
509
+ if isinstance(value, dict):
510
+ for key, item in value.items():
511
+ if field in {'kinds', 'states'}:
512
+ if field == 'kinds':
513
+ _report_identifier(key)
514
+ elif key not in _STATES | {'unknown'}:
515
+ raise ValueError()
516
+ if type(item) is not int or item < 0:
517
+ raise ValueError()
518
+ else:
519
+ _check_report_contract(item, key)
520
+ elif isinstance(value, list):
521
+ for item in value:
522
+ _check_report_contract(item, field)
523
+ elif field in _REPORT_REFERENCES:
524
+ if value is not None:
525
+ _report_identifier(value)
526
+ elif field == 'event_id':
527
+ if not isinstance(value, str) or not re.fullmatch(r'evt_[A-Za-z0-9_]{1,200}', value):
528
+ raise ValueError()
529
+ elif field in _REPORT_NUMBERS:
530
+ if value is None and field in _REPORT_NULLABLE_NUMBERS:
531
+ return
532
+ if type(value) not in (int, float) or not math.isfinite(value):
533
+ raise ValueError()
534
+ elif field == 'cancel_at_period_end':
535
+ if type(value) is not int or value not in (0, 1):
536
+ raise ValueError()
537
+ elif field in _REPORT_ENUMS:
538
+ if not isinstance(value, str) or value not in _REPORT_ENUMS[field]:
539
+ raise ValueError()
540
+ elif type(value) is not bool and (type(value) is not int or value < 0):
541
+ # Remaining scalar fields are builder-produced flags and aggregate counts.
542
+ # Future raw fields cannot silently export text/blob/float/null values.
543
+ raise ValueError()
544
+
545
+
546
+ def _inventory(db, records, tables):
547
+ users = {row[0] for row in db.execute('SELECT id FROM saas_users')}
548
+ billing = _dict_rows(db, 'saas_billing')
549
+ events = _dict_rows(db, 'saas_billing_events')
550
+ entitlements = _dict_rows(db, 'saas_entitlements')
551
+ owners = {(kind, record_id): owner for owner, kind, record_id in db.execute('SELECT user_id,kind,id FROM saas_record_owners')}
552
+ attempts = {row['attempt']: row for row in billing if row['attempt']}
553
+ event_keys = {':'.join((row['account_id'], row['mode'], row['event_id'])): row for row in events}
554
+ rows = []
555
+ for row in sorted(billing, key=lambda r: r['user_id'] or ''):
556
+ rows.append({
557
+ 'user_id': row['user_id'], 'customer_id': row['customer_id'],
558
+ 'subscription_id': row['subscription_id'], 'checkout_id': row['checkout_id'],
559
+ 'attempt': row['attempt'], 'customer_key_present': bool(row['customer_key']),
560
+ 'customer_started': row['customer_started'], 'attempt_started': row['attempt_started'],
561
+ 'leased': bool(row['lease']) or row['lease_until'] != 0, 'lease_until': row['lease_until'],
562
+ 'sync_due': row['sync_due'], 'cancel_at_period_end': row['cancel_at_period_end'],
563
+ 'review_present': bool(row['review']), 'orphan_user': row['user_id'] not in users,
564
+ 'checkout_intent_missing': bool(row['attempt']) and ('saas-checkout-intent', row['attempt']) not in records,
565
+ 'customer_outcome': 'pointer_present' if row['customer_id'] else 'uncertain',
566
+ 'checkout_outcome': 'pointer_present' if row['checkout_id'] else 'uncertain' if row['attempt'] else 'absent',
567
+ 'classification': 'review' if row['review'] else 'in_flight' if row['lease'] or row['lease_until'] else 'uncertain',
568
+ 'provenance': 'NOT_ESTABLISHED',
569
+ })
570
+ intents, payloads = [], []
571
+ for (kind, record_id), payload in sorted(records.items()):
572
+ if kind == 'saas-checkout-intent':
573
+ user = payload.get('user_id')
574
+ row = attempts.get(record_id)
575
+ owner = owners.get((kind, record_id))
576
+ intents.append({'kind': kind, 'id': record_id,
577
+ 'user_id': user if isinstance(user, str) else None,
578
+ 'orphan': row is None, 'orphan_user': user not in users if isinstance(user, str) else True,
579
+ 'ownership_discrepancy': bool(row and row['user_id'] != user) or bool(owner and owner != user),
580
+ 'deferred_parameters_present': isinstance(payload.get('deferred'), dict),
581
+ 'checkout_id': row['checkout_id'] if row else None,
582
+ 'outcome': 'uncertain', 'provenance': 'NOT_ESTABLISHED'})
583
+ elif kind == 'saas-stripe-event':
584
+ event = event_keys.get(record_id)
585
+ discrepancies = []
586
+ if event is not None:
587
+ if payload.get('id') != event['event_id']:
588
+ discrepancies.append('event_id')
589
+ if payload.get('account') not in (None, event['account_id']):
590
+ discrepancies.append('account_id')
591
+ expected_mode = {'test': False, 'live': True}.get(event['mode'])
592
+ if expected_mode is None or payload.get('livemode') is not expected_mode:
593
+ discrepancies.append('mode')
594
+ data = payload.get('data')
595
+ obj = data.get('object') if isinstance(data, dict) else None
596
+ obj = obj if isinstance(obj, dict) else {}
597
+ if obj.get('customer') != event['customer_id']:
598
+ discrepancies.append('customer_id')
599
+ metadata = obj.get('metadata')
600
+ payload_owner = metadata.get('user_id') if isinstance(metadata, dict) else None
601
+ sql_owners = {row['user_id'] for row in billing
602
+ if row['customer_id'] and row['customer_id'] == event['customer_id']}
603
+ if payload_owner is not None and sql_owners and payload_owner not in sql_owners:
604
+ discrepancies.append('owner')
605
+ payloads.append({'kind': kind, 'id': record_id, 'orphan': record_id not in event_keys,
606
+ 'local_discrepancies': discrepancies,
607
+ 'outcome': 'uncertain', 'provenance': 'NOT_ESTABLISHED'})
608
+ event_rows = []
609
+ for row in sorted(events, key=lambda r: (r['account_id'], r['mode'], r['event_id'])):
610
+ key = ':'.join((row['account_id'], row['mode'], row['event_id']))
611
+ event_rows.append({'account_id': row['account_id'], 'mode': row['mode'], 'event_id': row['event_id'],
612
+ 'customer_id': row['customer_id'], 'state': _state(row['state']),
613
+ 'encrypted_payload_present': ('saas-stripe-event', key) in records,
614
+ 'customer_pointer_present': any(r['customer_id'] == row['customer_id'] for r in billing) if row['customer_id'] else False,
615
+ 'outcome': 'terminal' if row['state'] in {'done', 'ignored'} else 'uncertain',
616
+ 'provenance': 'NOT_ESTABLISHED'})
617
+ bounds = [{**row, 'subscription_status': _state(row['subscription_status'], _SUBSCRIPTIONS)}
618
+ for row in sorted(entitlements, key=lambda r: r['user_id'] or '')]
619
+ paid = [row for row in bounds if row['paid_until'] > 0 or row['subscription_status'] != '']
620
+ marker_indexes = sum(db.execute('SELECT count(*) FROM '+table+' WHERE kind=? AND id=?',
621
+ ('saas-billing-environment', 'active')).fetchone()[0]
622
+ for table in ('saas_record_owners', 'saas_record_links', 'saas_record_emails'))
623
+ counts = {}
624
+ for kind, _ in records:
625
+ counts[kind] = counts.get(kind, 0) + 1
626
+ # Unknown kinds are authenticated, retained in the whole-state root and
627
+ # explicitly inventoried. They never establish authority or eligibility.
628
+ return {
629
+ 'marker': {'state': 'present' if ('saas-billing-environment', 'active') in records else 'absent',
630
+ 'index_rows': marker_indexes},
631
+ 'records': {'authenticated': len(records), 'kinds': dict(sorted(counts.items())),
632
+ 'unknown': [{'kind': kind, 'id': record_id} for kind, record_id in sorted(records) if kind not in _KNOWN_KINDS]},
633
+ 'billing': {'legacy_guard_present': bool(rows or events or paid or intents or payloads),
634
+ 'rows': rows, 'events': event_rows, 'intents': intents,
635
+ 'event_payloads': payloads, 'paid_state': paid},
636
+ 'access': {'bounds': bounds, 'owner_reviewer_policy': 'NOT_ESTABLISHED',
637
+ 'user_rows': len(users), 'trial_marker_rows': tables['saas_trial_markers']['rows']},
638
+ 'restore': {'state': 'ready' if 'saas_restore_guard' in tables else 'absent'},
639
+ 'coverage': {'all_tables': True, 'all_records_including_expired': True,
640
+ 'deletions': _jobs(db, 'saas_deletions'),
641
+ 'revocations': _jobs(db, 'saas_google_revocations'),
642
+ 'reminders': _jobs(db, 'saas_reminders'),
643
+ 'deletion_tombstone_rows': tables['saas_deletion_tombstones']['rows'],
644
+ 'legacy_cutoff_rows': tables.get('legacy_cutoff', {}).get('rows', 0),
645
+ 'owner_index_rows': tables['saas_record_owners']['rows'],
646
+ 'link_index_rows': tables['saas_record_links']['rows'],
647
+ 'email_index_rows': tables['saas_record_emails']['rows']},
648
+ }
649
+
650
+
651
+ def _inspection_report(schema, identity, tables, schema_root, data_root, report):
652
+ """Shared exact report builder; enrollment does not maintain a second format."""
653
+ root = _state_root(schema_root, data_root, identity)
654
+ return {'format': 'socialcli-billing-inventory-v1', 'status': 'inventory_only',
655
+ 'enrollment_authorized': False, 'schema_version': 10,
656
+ 'schema_variant': _schema_variant(schema),
657
+ 'target': identity, 'schema_root': schema_root, 'state_root': root,
658
+ 'tables': tables, **report,
659
+ 'not_established': dict.fromkeys((
660
+ 'historical_account_mode_provenance', 'provider_get_validation',
661
+ 'owner_approval', 'reviewer_approval', 'live_freshness',
662
+ 'current_authority', 'external_auth_authority', 'deployment_lineage'), 'NOT_ESTABLISHED')}
663
+
664
+
665
+ def inspect_database(database: Path, keys: list[bytes]) -> dict:
666
+ """Read only the exact existing private file; return private inventory evidence.
667
+
668
+ A WAL database requires existing protected WAL/SHM files so opening it cannot
669
+ create sidecars. Committed WAL pages are included; immutable/nolock are never
670
+ used. A concurrent writer may commit after the read snapshot is pinned, so
671
+ this evidence is not freshness, authority, or an apply-time CAS.
672
+ """
673
+ try:
674
+ database = _private_path(database)
675
+ before = _file_info(database)
676
+ identity = _identity(before)
677
+ fd = os.open(database, os.O_RDONLY | os.O_NOFOLLOW)
678
+ try:
679
+ _assert_identity(database, identity, fd)
680
+ header = os.pread(fd, 100, 0)
681
+ if len(header) < 100 or header[:16] != b'SQLite format 3\0':
682
+ raise ValueError()
683
+ if header[18:20] == b'\x02\x02':
684
+ for suffix in ('-wal', '-shm'):
685
+ _file_info(Path(str(database)+suffix))
686
+ elif header[18:20] != b'\x01\x01':
687
+ raise ValueError()
688
+ with closing(sqlite3.connect(database.as_uri()+'?mode=ro', uri=True, timeout=5)) as db:
689
+ db.execute('PRAGMA query_only=ON')
690
+ db.execute('PRAGMA trusted_schema=OFF')
691
+ db.execute('BEGIN')
692
+ schema = _schema(db) # Pins the snapshot before any inventory.
693
+ _assert_identity(database, identity, fd)
694
+ tables, schema_root, data_root = _logical_state(db, schema)
695
+ records = _authenticate(db, keys)
696
+ report = _inventory(db, records, tables)
697
+ _check_report_contract(report)
698
+ _assert_identity(database, identity, fd)
699
+ db.rollback()
700
+ _assert_identity(database, identity, fd)
701
+ return _inspection_report(schema, identity, tables, schema_root, data_root, report)
702
+ finally:
703
+ os.close(fd)
704
+ except Exception:
705
+ raise InventoryError(_ERROR) from None
706
+
707
+
708
+ def _destination(path):
709
+ path = _private_path(path)
710
+ # A linked checkout has a .git file; a regular repository has a directory.
711
+ if any((parent / '.git').exists() or (parent / '.git').is_symlink() for parent in path.parents):
712
+ raise ValueError()
713
+ if path.exists() or path.is_symlink():
714
+ raise ValueError()
715
+ return path
716
+
717
+
718
+ def write_inventory(database: Path, keys: list[bytes], destination: Path) -> dict:
719
+ """Create a new 0600 report in existing private storage outside Git."""
720
+ created = False
721
+ try:
722
+ destination = _destination(destination)
723
+ report = inspect_database(database, keys)
724
+ encoded = (json.dumps(report, sort_keys=True, indent=2, allow_nan=False)+'\n').encode()
725
+ fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
726
+ created = True
727
+ with os.fdopen(fd, 'wb') as stream:
728
+ stream.write(encoded)
729
+ stream.flush()
730
+ os.fsync(stream.fileno())
731
+ directory_fd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
732
+ try:
733
+ os.fsync(directory_fd)
734
+ finally:
735
+ os.close(directory_fd)
736
+ return {'status': 'inventory_only', 'tables': len(report['tables']),
737
+ 'rows': sum(table['rows'] for table in report['tables'].values()),
738
+ 'records': report['records']['authenticated'], 'state_root': report['state_root']}
739
+ except Exception:
740
+ if created:
741
+ try:
742
+ destination.unlink(missing_ok=True)
743
+ except OSError:
744
+ pass # A failed partial artifact is private, never success.
745
+ raise InventoryError(_ERROR) from None
746
+
747
+
748
+ def main():
749
+ """Paths and runtime keys are supplied privately, never as arguments."""
750
+ try:
751
+ if len(sys.argv) != 1:
752
+ raise ValueError()
753
+ raw_keys = os.environ.get('SOCIALCLI_ENCRYPTION_KEYS', '')
754
+ key_file = os.environ.get('SOCIALCLI_ENCRYPTION_KEYS_FILE', '')
755
+ if bool(raw_keys) == bool(key_file):
756
+ raise ValueError()
757
+ if key_file:
758
+ path = _private_path(key_file)
759
+ info = _file_info(path)
760
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
761
+ with os.fdopen(fd, 'r') as stream:
762
+ if _identity(os.fstat(stream.fileno())) != _identity(info):
763
+ raise ValueError()
764
+ raw_keys = stream.read(65537)
765
+ if len(raw_keys) > 65536:
766
+ raise ValueError()
767
+ _assert_identity(path, _identity(info), stream.fileno())
768
+ keys = [value.strip().encode('ascii') for value in raw_keys.split(',') if value.strip()]
769
+ summary = write_inventory(Path(os.environ['SOCIALCLI_BILLING_INVENTORY_DATABASE']), keys,
770
+ Path(os.environ['SOCIALCLI_BILLING_INVENTORY_DESTINATION']))
771
+ print(json.dumps(summary, sort_keys=True))
772
+ return 0
773
+ except Exception:
774
+ print(json.dumps({'status': 'failed', 'error': _ERROR}))
775
+ return 1