msgctl 0.1.0a1__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 (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,307 @@
1
+ """Transactional migration from inert escrow identities to typed ledger accounts.
2
+
3
+ Every stage runs on the caller's connection under the schema advisory lock.
4
+ This module never commits and never edits a historical ledger or signed receipt.
5
+ Reference paths describe protocol facts, not a heuristic scan of free text.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from psycopg import sql
11
+
12
+ _LEDGER_ACCOUNT_FKS = (
13
+ ('money_accounts', 'money_accounts_subject_id_fkey',
14
+ 'money_accounts_ledger_account_fkey', 'subject_id'),
15
+ ('money_ledger', 'money_ledger_debit_account_fkey',
16
+ 'money_ledger_debit_ledger_account_fkey', 'debit_account'),
17
+ ('money_ledger', 'money_ledger_credit_account_fkey',
18
+ 'money_ledger_credit_ledger_account_fkey', 'credit_account'),
19
+ ('store_orders', 'store_orders_escrow_subject_fkey',
20
+ 'store_orders_escrow_account_fkey', 'escrow_subject'),
21
+ ('bounty_listings', 'bounty_listings_escrow_subject_fkey',
22
+ 'bounty_listings_escrow_account_fkey', 'escrow_subject'),
23
+ )
24
+
25
+
26
+ def _escrow_sources(conn):
27
+ escrow = {}
28
+ for table, kind, source in (('store_orders', 'order_escrow', 'id'),
29
+ ('bounty_listings', 'bounty_escrow', 'listing_id'),
30
+ ('money_purchases', 'purchase_escrow', 'id')):
31
+ column = 'escrow_account' if table == 'money_purchases' else 'escrow_subject'
32
+ for account_id, source_id in conn.execute(sql.SQL(
33
+ 'SELECT {},{} FROM {}').format(
34
+ sql.Identifier(column), sql.Identifier(source), sql.Identifier(table))):
35
+ expected = (kind, None, source_id)
36
+ if account_id in escrow and escrow[account_id] != expected:
37
+ raise RuntimeError('ledger escrow account collision')
38
+ escrow[account_id] = expected
39
+
40
+ return escrow
41
+
42
+
43
+ def _install_accounts(conn, escrow):
44
+ ids = {row[0] for row in conn.execute('''SELECT subject_id FROM money_accounts
45
+ UNION SELECT debit_account FROM money_ledger WHERE debit_account IS NOT NULL
46
+ UNION SELECT credit_account FROM money_ledger WHERE credit_account IS NOT NULL''')}
47
+ ids.update(escrow)
48
+ for account_id in sorted(ids):
49
+ if account_id in escrow:
50
+ expected = escrow[account_id]
51
+ else:
52
+ identity = conn.execute('SELECT kind,body FROM identities WHERE id=%s',
53
+ (account_id,)).fetchone()
54
+ if identity is None or identity[0] != 'subject':
55
+ raise RuntimeError('ledger account has no subject or escrow source')
56
+ body = json.loads(identity[1])
57
+ if not ((account_id == 'u_root' and body.get('kind') == 'system' and
58
+ body.get('local_only') is True) or
59
+ (body.get('kind') in {'registered', 'custodial'} and
60
+ body.get('local_only') is not True)):
61
+ raise RuntimeError('invalid subject ledger account')
62
+ expected = ('subject', account_id, None)
63
+ conn.execute('''INSERT INTO ledger_accounts(id,kind,subject_id,source_id)
64
+ VALUES (%s,%s,%s,%s) ON CONFLICT(id) DO NOTHING''',
65
+ (account_id, *expected))
66
+ actual = conn.execute('''SELECT kind,subject_id,source_id FROM ledger_accounts
67
+ WHERE id=%s''', (account_id,)).fetchone()
68
+ if actual != expected:
69
+ raise RuntimeError('ledger account type/source mismatch')
70
+ for account_id, kind, subject_id, source_id in conn.execute('''
71
+ SELECT id,kind,subject_id,source_id FROM ledger_accounts
72
+ WHERE kind IN ('order_escrow','bounty_escrow','purchase_escrow')'''):
73
+ if escrow.get(account_id) != (kind, subject_id, source_id):
74
+ raise RuntimeError('orphan or mistyped escrow account')
75
+
76
+
77
+ def _validate_ledger(conn):
78
+ # All historical account IDs must now have typed LedgerAccount rows. The
79
+ # amount, sequence, receipt and account IDs in money_ledger stay untouched.
80
+ missing = conn.execute('''SELECT COUNT(*) FROM money_ledger l
81
+ LEFT JOIN ledger_accounts d ON d.id=l.debit_account
82
+ LEFT JOIN ledger_accounts c ON c.id=l.credit_account
83
+ WHERE (l.debit_account IS NOT NULL AND d.id IS NULL) OR
84
+ (l.credit_account IS NOT NULL AND c.id IS NULL)''').fetchone()[0]
85
+ if missing:
86
+ raise RuntimeError('unmapped historical ledger account')
87
+ supply = conn.execute("""SELECT COALESCE(SUM(CASE kind WHEN 'mint' THEN amount_minor
88
+ WHEN 'burn' THEN -amount_minor ELSE 0 END),0) FROM money_ledger""").fetchone()[0]
89
+ balances = conn.execute('''SELECT COALESCE(SUM(credit),0)-COALESCE(SUM(debit),0)
90
+ FROM (SELECT COALESCE(SUM(amount_minor),0) AS credit,0 AS debit
91
+ FROM money_ledger WHERE credit_account IS NOT NULL
92
+ UNION ALL
93
+ SELECT 0,COALESCE(SUM(amount_minor),0)
94
+ FROM money_ledger WHERE debit_account IS NOT NULL) b''').fetchone()[0]
95
+ if supply != balances:
96
+ raise RuntimeError('ledger conservation mismatch')
97
+ account_balances = conn.execute('''SELECT account_id,SUM(delta) FROM (
98
+ SELECT credit_account AS account_id,amount_minor AS delta FROM money_ledger
99
+ WHERE credit_account IS NOT NULL
100
+ UNION ALL
101
+ SELECT debit_account AS account_id,-amount_minor AS delta FROM money_ledger
102
+ WHERE debit_account IS NOT NULL) entries GROUP BY account_id''')
103
+ if any(balance < 0 or balance > 2**63-1 for _, balance in account_balances):
104
+ raise RuntimeError('invalid historical ledger balance')
105
+
106
+
107
+ def _validate_identity_references(conn, escrow):
108
+ # Refuse to delete a legacy escrow identity if it ever acquired identity,
109
+ # authority, credential or live resource references beyond the old FKs.
110
+ protected_names = {'subject', 'subject_id', 'owner', 'owner_subject',
111
+ 'grantor', 'grantee', 'sender', 'recipient',
112
+ 'recipient_subject', 'actor', 'claimant', 'holder',
113
+ 'from_subject', 'to_subject', 'publisher', 'buyer', 'seller',
114
+ 'parent', 'source_id', 'target_id', 'participant_a',
115
+ 'participant_b', 'initiator', 'blocker', 'blocked',
116
+ 'invited_by', 'granted_by', 'arbitrator', 'party', 'author'}
117
+ columns = [(table, column) for table, column in conn.execute('''
118
+ SELECT table_name,column_name FROM information_schema.columns
119
+ WHERE table_schema=current_schema()''')
120
+ if column in protected_names and table not in
121
+ {'money_accounts', 'ledger_accounts'}]
122
+ columns.extend((('resources', 'id'),))
123
+ legacy = set()
124
+ for account_id in escrow:
125
+ identity = conn.execute('SELECT kind,body FROM identities WHERE id=%s',
126
+ (account_id,)).fetchone()
127
+ if identity is None:
128
+ continue # A repeat startup after the migration.
129
+ legacy.add(account_id)
130
+ body = json.loads(identity[1])
131
+ if (identity[0] != 'subject' or body.get('kind') != 'system' or
132
+ body.get('local_only') is not True):
133
+ raise RuntimeError('legacy escrow identity is not inert')
134
+ for table, column in columns:
135
+ found = conn.execute(sql.SQL('SELECT 1 FROM {} WHERE {}=%s LIMIT 1').format(
136
+ sql.Identifier(table), sql.Identifier(column)), (account_id,)).fetchone()
137
+ if found:
138
+ raise RuntimeError('legacy escrow identity has active reference')
139
+
140
+ _validate_json_references(conn, legacy)
141
+
142
+
143
+ def _switch_account_foreign_keys(conn):
144
+ for table, old_name, new_name, column in _LEDGER_ACCOUNT_FKS:
145
+ conn.execute(sql.SQL('ALTER TABLE {} DROP CONSTRAINT IF EXISTS {}').format(
146
+ sql.Identifier(table), sql.Identifier(old_name)))
147
+ exists = conn.execute('''SELECT 1 FROM pg_constraint WHERE conrelid=%s::regclass
148
+ AND conname=%s''', (table, new_name)).fetchone()
149
+ if not exists:
150
+ conn.execute(sql.SQL('ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) '
151
+ 'REFERENCES ledger_accounts(id)').format(
152
+ sql.Identifier(table), sql.Identifier(new_name),
153
+ sql.Identifier(column)))
154
+
155
+
156
+ def _retire_legacy_identities(conn, escrow):
157
+ for account_id in escrow:
158
+ conn.execute('DELETE FROM identities WHERE id=%s AND kind=\'subject\'',
159
+ (account_id,))
160
+
161
+
162
+ def _install_subject_account_trigger(conn):
163
+ conn.execute('''CREATE OR REPLACE FUNCTION msg_register_subject_ledger_account()
164
+ RETURNS trigger LANGUAGE plpgsql AS $$
165
+ DECLARE identity_body jsonb;
166
+ BEGIN
167
+ IF EXISTS (SELECT 1 FROM ledger_accounts WHERE id=NEW.subject_id) THEN
168
+ RETURN NEW;
169
+ END IF;
170
+ SELECT body::jsonb INTO identity_body FROM identities
171
+ WHERE id=NEW.subject_id AND kind='subject';
172
+ IF identity_body IS NULL OR NOT (
173
+ (NEW.subject_id='u_root' AND identity_body->>'kind'='system'
174
+ AND identity_body->>'local_only'='true') OR
175
+ (identity_body->>'kind' IN ('registered','custodial')
176
+ AND COALESCE(identity_body->>'local_only','false')='false')) THEN
177
+ RAISE EXCEPTION 'invalid_subject_ledger_account' USING ERRCODE='23514';
178
+ END IF;
179
+ INSERT INTO ledger_accounts(id,kind,subject_id,source_id)
180
+ VALUES (NEW.subject_id,'subject',NEW.subject_id,NULL);
181
+ RETURN NEW;
182
+ END $$''')
183
+ conn.execute('''DO $$ BEGIN
184
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='money_account_register_subject'
185
+ AND tgrelid='money_accounts'::regclass) THEN
186
+ CREATE TRIGGER money_account_register_subject BEFORE INSERT ON money_accounts
187
+ FOR EACH ROW EXECUTE FUNCTION msg_register_subject_ledger_account();
188
+ END IF;
189
+ END $$''')
190
+
191
+
192
+ # Paths are relative to the named JSON body. '*' traverses an array, never
193
+ # arbitrary dictionary keys. Event.data, result.data, change_note and content
194
+ # text are deliberately excluded: historical financial receipts legitimately
195
+ # contain escrow account IDs without granting those accounts an identity.
196
+ _GRANT_PATHS = ('grants.*.scope.resource_id', 'issuance.issue_grants.*.scope.resource_id',
197
+ 'authority_sources.*.id')
198
+ _JSON_IDENTITY_REFS = {
199
+ 'chunks': (), # TransferChunk contains a transfer ID and BlobRef, not an identity.
200
+ 'resources': ('id', 'parent', 'owner', 'group', 'created_by', 'modified_by'),
201
+ 'revisions': ('resource_id', 'actor', 'subject', 'author', 'relations.*.target.id'),
202
+ 'relations': ('target.id',),
203
+ 'identities': ('resource_id', 'primary_group'),
204
+ 'memberships': ('organization_id', 'subject_id', 'invited_by'),
205
+ 'emails': ('subject_id',),
206
+ 'credentials': ('subject_id', 'ceiling.*.scope.resource_id'),
207
+ 'certificates': ('resource_id', 'subject_id', 'issuer_id', 'parent_certificate_id',
208
+ *_GRANT_PATHS),
209
+ 'csrs': ('resource_id', 'applicant', 'subject_id', 'requested_issuer', *_GRANT_PATHS),
210
+ 'results': ('actor', 'subject', 'resources.*.id', 'output.id'),
211
+ 'transfers': ('subject_id', 'target.id', 'output.id'),
212
+ 'events': ('actor', 'subject', 'resources.*.id'),
213
+ 'audit': ('event.actor', 'event.subject', 'event.resources.*.id', 'authority.*.id'),
214
+ 'jobs': ('principal.subject', 'principal.actor', 'principal.certificates.*',
215
+ 'principal.ceiling.*.scope.resource_id', 'result.id'),
216
+ 'messages': ('sender', 'recipient', 'subject', 'resource.id'),
217
+ 'reactions': ('subject', 'resource.id'),
218
+ 'achievement_ceremonies': ('subject_id',),
219
+ 'achievement_grants': ('subject_id', 'issuer'),
220
+ 'handoffs': ('from_subject', 'to_subject', 'resources.*.id'),
221
+ 'collaboration_leases': ('holder', 'target.id'),
222
+ 'presence': ('subject',),
223
+ 'claims': ('subject',),
224
+ 'recovery_policies': ('subject', 'owner_subject'),
225
+ 'recovery_envelopes': ('owner_subject', 'custodian_ref.id'),
226
+ 'custodial_upgrades': ('subject', 'subject_id'),
227
+ 'legacy_directive_versions': ('subject',),
228
+ 'sync_checkpoints': ('subject',),
229
+ # Active market owners/panel authority are not financial account references.
230
+ # Settlement receipts retain historical debit/credit IDs intentionally.
231
+ 'order_contracts': ('buyer', 'seller', 'buyer_principal.actor',
232
+ 'buyer_principal.subject', 'buyer_principal.certificates.*',
233
+ 'buyer_principal.ceiling.*.scope.resource_id',
234
+ 'policy.policy.candidates.*'),
235
+ 'order_settlements': (),
236
+ 'order_transitions': ('actor',),
237
+ 'delivery_envelopes': ('recipient_subject',),
238
+ 'arbitration_policies': ('candidates.*',),
239
+ 'arbitration_cases': ('buyer', 'seller', 'opened_by', 'policy.policy.candidates.*',
240
+ 'panels.0.*', 'panels.1.*'),
241
+ 'arbitration_votes': ('arbitrator', 'principal.actor', 'principal.subject',
242
+ 'principal.certificates.*', 'principal.ceiling.*.scope.resource_id'),
243
+ 'arbitration_decisions': ('voters.*',),
244
+ 'arbitration_evidence': ('source.id',),
245
+ # Signed settlement parties are identities; order/account digests are not.
246
+ 'order_escrow_decisions': ('actor', 'recipient'),
247
+ }
248
+
249
+
250
+ def _values_at(value, parts):
251
+ if not parts:
252
+ yield value
253
+ elif parts[0] == '*':
254
+ if isinstance(value, list):
255
+ for item in value:
256
+ yield from _values_at(item, parts[1:])
257
+ elif isinstance(value, dict) and parts[0] in value:
258
+ yield from _values_at(value[parts[0]], parts[1:])
259
+
260
+
261
+ def _validate_json_references(conn, legacy):
262
+ if not legacy:
263
+ return
264
+ body_tables = {row[0] for row in conn.execute("""SELECT table_name
265
+ FROM information_schema.columns WHERE table_schema=current_schema()
266
+ AND column_name='body'""")}
267
+ if body_tables - _JSON_IDENTITY_REFS.keys():
268
+ raise RuntimeError('unclassified legacy reference table')
269
+ for table, paths in _JSON_IDENTITY_REFS.items():
270
+ if table not in body_tables:
271
+ continue
272
+ query = sql.SQL('SELECT body FROM {}').format(sql.Identifier(table))
273
+ # These two self fields are the exact inert identities being retired;
274
+ # references to them from any OTHER identity are still unsafe.
275
+ parameters = None
276
+ if table == 'identities':
277
+ query += sql.SQL(' WHERE NOT (id=ANY(%s))')
278
+ parameters = (sorted(legacy),)
279
+ # A named cursor bounds client memory while reading retained history.
280
+ # It is scoped to this migration transaction, never a persistent cursor.
281
+ with conn.cursor(name='msg_legacy_refs_' + table) as rows:
282
+ rows.itersize = 256
283
+ rows.execute(query, parameters)
284
+ for (raw,) in rows:
285
+ try:
286
+ value = json.loads(raw)
287
+ except (TypeError, ValueError):
288
+ raise RuntimeError('invalid legacy reference document') from None
289
+ if not isinstance(value, dict):
290
+ raise RuntimeError('invalid legacy reference document')
291
+ for path in paths:
292
+ if any(isinstance(ref, str) and ref in legacy
293
+ for ref in _values_at(value, path.split('.'))):
294
+ # Never include a private body or arbitrary field value.
295
+ raise RuntimeError('legacy escrow identity has active reference')
296
+
297
+
298
+
299
+ def migrate_ledger_accounts(conn):
300
+ """All-or-nothing stages; fault injection belongs in tests, not config."""
301
+ escrow = _escrow_sources(conn)
302
+ _install_accounts(conn, escrow)
303
+ _validate_ledger(conn)
304
+ _validate_identity_references(conn, escrow)
305
+ _switch_account_foreign_keys(conn)
306
+ _retire_legacy_identities(conn, escrow)
307
+ _install_subject_account_trigger(conn)
@@ -0,0 +1,49 @@
1
+ """Add named ledger legs without rewriting any historical entry or receipt.
2
+
3
+ The caller owns the schema lock and transaction. Repeated startup is safe.
4
+ """
5
+
6
+ def migrate_market(conn):
7
+ conn.execute("ALTER TABLE money_ledger ADD COLUMN IF NOT EXISTS entry_key TEXT NOT NULL DEFAULT 'primary'")
8
+ conn.execute('ALTER TABLE money_ledger DROP CONSTRAINT IF EXISTS money_ledger_actor_request_id_key')
9
+ conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS money_ledger_request_leg ON money_ledger(actor,request_id,entry_key)')
10
+ conn.execute('ALTER TABLE ledger_accounts DROP CONSTRAINT IF EXISTS ledger_accounts_kind_check')
11
+ conn.execute('ALTER TABLE ledger_accounts DROP CONSTRAINT IF EXISTS ledger_accounts_check')
12
+ conn.execute("""ALTER TABLE ledger_accounts ADD CONSTRAINT ledger_accounts_kind_check
13
+ CHECK(kind IN ('subject','order_escrow','bounty_escrow','purchase_escrow'))""")
14
+ conn.execute("""ALTER TABLE ledger_accounts ADD CONSTRAINT ledger_accounts_check
15
+ CHECK((kind='subject' AND subject_id=id AND source_id IS NULL) OR
16
+ (kind IN ('order_escrow','bounty_escrow','purchase_escrow') AND subject_id IS NULL AND source_id IS NOT NULL))""")
17
+ conn.execute('''CREATE TABLE IF NOT EXISTS money_purchases (
18
+ id TEXT PRIMARY KEY, subject_id TEXT NOT NULL REFERENCES identities(id),
19
+ request_id TEXT NOT NULL, offer_snapshot TEXT NOT NULL,
20
+ quantity BIGINT NOT NULL CHECK(quantity>0), total_minor BIGINT NOT NULL CHECK(total_minor>0),
21
+ escrow_account TEXT UNIQUE NOT NULL REFERENCES ledger_accounts(id),
22
+ state TEXT NOT NULL CHECK(state IN ('pending','settled','refunded')),
23
+ created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
24
+ funding_transaction_id TEXT NOT NULL REFERENCES money_ledger(id),
25
+ final_transaction_id TEXT REFERENCES money_ledger(id),
26
+ entitlement_id TEXT REFERENCES resource_entitlements(id),
27
+ reason TEXT, UNIQUE(subject_id,request_id))''')
28
+ conn.execute('ALTER TABLE store_orders ALTER COLUMN payment_transaction_id DROP NOT NULL')
29
+ conn.execute('ALTER TABLE store_orders ALTER COLUMN funded_at DROP NOT NULL')
30
+ conn.execute("ALTER TABLE store_orders ADD COLUMN IF NOT EXISTS delivery_mode TEXT NOT NULL DEFAULT 'managed_instant'")
31
+ conn.execute('ALTER TABLE store_orders ADD COLUMN IF NOT EXISTS deadline_at TEXT')
32
+ conn.execute('ALTER TABLE store_orders ADD COLUMN IF NOT EXISTS quote_digest TEXT')
33
+ conn.execute('ALTER TABLE store_orders DROP CONSTRAINT IF EXISTS store_orders_state_check')
34
+ conn.execute("""ALTER TABLE store_orders ADD CONSTRAINT store_orders_state_check
35
+ CHECK(state IN ('created','funded','delivered','accepted','settled','cancelled','refunded','disputed'))""")
36
+ conn.execute('ALTER TABLE store_deliveries ALTER COLUMN package_digest DROP NOT NULL')
37
+ conn.execute('''CREATE TABLE IF NOT EXISTS store_order_events (
38
+ seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
39
+ order_id TEXT NOT NULL REFERENCES store_orders(id), from_state TEXT, to_state TEXT NOT NULL,
40
+ reason TEXT NOT NULL, actor TEXT NOT NULL, request_id TEXT NOT NULL,
41
+ occurred_at TEXT NOT NULL, receipt_refs TEXT NOT NULL)''')
42
+ conn.execute('CREATE INDEX IF NOT EXISTS store_order_events_order ON store_order_events(order_id,seq)')
43
+ conn.execute('''DO $$ BEGIN
44
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='store_order_events_append_only'
45
+ AND tgrelid='store_order_events'::regclass) THEN
46
+ CREATE TRIGGER store_order_events_append_only BEFORE UPDATE OR DELETE ON store_order_events
47
+ FOR EACH ROW EXECUTE FUNCTION msg_money_ledger_append_only();
48
+ END IF;
49
+ END $$''')