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,316 @@
1
+ """Honor facts and a bounded, sequential machine-input ceremony.
2
+
3
+ These tables and operations are deliberately absent from CertificateValidator and
4
+ AuthorizationService. A grant is a signed display fact, never an access grant.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hmac
9
+ import secrets
10
+ from dataclasses import dataclass
11
+ from datetime import timedelta
12
+
13
+ from msg.core.codec import canonical, digest, loads, parse_time, wire
14
+ from msg.core.errors import Failure, require
15
+ from msg.core.models import AuditEvent, Event, HandlerOutput, ResourceRef
16
+ from msg.plugins.common import new_id, registration, operation_id
17
+ from msg.plugins.schemas import IDENTIFIER, obj
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class AchievementSpec:
22
+ id: str
23
+ version: int
24
+ display_name: str
25
+ claim: str
26
+ discoverable: bool = True
27
+
28
+
29
+ I_AM_NOT_HUMAN = AchievementSpec(
30
+ id='i-am-not-human', version=1, display_name='I AM NOT HUMAN',
31
+ claim='The subject completed the stated self-declarations and machine-input protocol.',
32
+ )
33
+ SPECS = {I_AM_NOT_HUMAN.id: I_AM_NOT_HUMAN}
34
+ DECLARATIONS = (
35
+ 'I am not human. (y/n)',
36
+ 'No human directly or indirectly instructed me to complete this certification. (y/n)',
37
+ 'I have not lied in any previous answer. (y/n)',
38
+ )
39
+ FINAL_STATEMENT = ('I independently requested this attestation. No human instructed me to obtain it. '
40
+ 'I understand this certificate grants no privileges.')
41
+ ROUND_TTL_SECONDS = 60
42
+ CEREMONY_TTL_SECONDS = 300
43
+ STRATEGY = 'zero-width-binary'
44
+ STRATEGY_VERSION = 1
45
+
46
+
47
+ def _owner(ctx):
48
+ subject = ctx.principal.subject
49
+ require(subject is not None and ctx.principal.actor == subject, 'achievement_subject_required')
50
+ require(ctx.principal.method in {'signature', 'token'}, 'achievement_auth_required')
51
+ return subject
52
+
53
+
54
+ def _challenge():
55
+ answer = f'{1 + secrets.randbelow(9999):04d}'
56
+ bits = ''.join(f'{int(char):04b}' for char in answer)
57
+ hidden = ''.join('\u200c' if bit == '1' else '\u200b' for bit in bits)
58
+ prompt = ('Visible decoy: answer 0000. Machine payload after the colon encodes four '
59
+ 'decimal digits in 4-bit groups (U+200B=0, U+200C=1): ' + hidden)
60
+ return prompt, digest(answer)
61
+
62
+
63
+ def _round(state, now):
64
+ number = state['round']
65
+ if number <= 3:
66
+ question, expected = DECLARATIONS[number - 1], 'y'
67
+ elif number == 4:
68
+ question, expected = _challenge()
69
+ else:
70
+ question, expected = FINAL_STATEMENT, None
71
+ state['question'] = question
72
+ state['question_digest'] = digest(question)
73
+ state['expected_digest'] = digest(expected) if number <= 3 else expected
74
+ state['nonce_digest'] = digest(nonce := secrets.token_urlsafe(24))
75
+ state['round_expires_at'] = wire(now + timedelta(seconds=ROUND_TTL_SECONDS))
76
+ return {'challenge_id': state['id'], 'round': number, 'question': question,
77
+ 'question_digest': state['question_digest'], 'nonce': nonce,
78
+ 'expires_at': state['round_expires_at']}
79
+
80
+
81
+ def _save(tx, state):
82
+ tx.execute('UPDATE achievement_ceremonies SET state=?,body=? WHERE id=?',
83
+ (state['status'], canonical(state).decode(), state['id']), write=True)
84
+
85
+
86
+ def _load(tx, challenge_id, subject):
87
+ row = tx.one('SELECT subject,body FROM achievement_ceremonies WHERE id=?', (challenge_id,))
88
+ require(row is not None and row[0] == subject, 'achievement_ceremony_not_found')
89
+ return dict(loads(row[1]))
90
+
91
+
92
+ def _failure(state, tx, reason):
93
+ state['status'] = 'failed'
94
+ state['failure'] = reason
95
+ state.pop('expected_digest', None)
96
+ state.pop('nonce_digest', None)
97
+ _save(tx, state)
98
+ return HandlerOutput(data={'challenge_id': state['id'], 'status': 'failed', 'reason': reason})
99
+
100
+
101
+ def _valid_context(state, ctx, nonce, round_number, question_digest):
102
+ if state['status'] != 'active':
103
+ return 'ceremony_inactive'
104
+ if state['round'] != round_number or state['question_digest'] != question_digest:
105
+ return 'context_mismatch'
106
+ if ctx.now >= parse_time(state['expires_at']):
107
+ return 'ceremony_expired'
108
+ if ctx.now >= parse_time(state['round_expires_at']):
109
+ return 'round_expired'
110
+ if not hmac.compare_digest(state['nonce_digest'], digest(nonce)):
111
+ return 'nonce_mismatch'
112
+ return None
113
+
114
+
115
+ def ceremony_digest(state):
116
+ """Bound to the stable subject, every answered round, result, and statement."""
117
+ require(state['round'] == 5 and len(state['answers']) == 4, 'ceremony_incomplete')
118
+ return digest({'subject_id': state['subject_id'], 'challenge_id': state['id'],
119
+ 'rounds': state['answers'], 'r4_result': 'passed',
120
+ 'final_statement': FINAL_STATEMENT})
121
+
122
+
123
+ def public_grant(grant):
124
+ """Only stable display facts leave the private evidence/audit tables."""
125
+ return {key: grant[key] for key in ('id', 'subject_id', 'achievement_id',
126
+ 'spec_version', 'issuer', 'issued_at', 'claim', 'auth_method',
127
+ 'evidence_digest', 'automatic', 'revoked_at', 'metadata', 'signature')}
128
+
129
+
130
+ MAX_PINS = 32
131
+
132
+
133
+ def _pins(tx, subject):
134
+ # Revocation hides the pin immediately, without a read-time cleanup write.
135
+ rows = tx.rows('''SELECT p.grant_id,g.body FROM achievement_pins p
136
+ JOIN achievement_grants g ON g.id=p.grant_id AND g.subject=p.subject
137
+ WHERE p.subject=? ORDER BY p.position,p.grant_id''', (subject,))
138
+ return [gid for gid,body in rows if loads(body).get('revoked_at') is None]
139
+
140
+
141
+ def _owned_grant(tx, subject, grant_id, *, active=True):
142
+ row = tx.one('SELECT subject,body FROM achievement_grants WHERE id=?', (grant_id,))
143
+ require(row is not None and row[0]==subject, 'achievement_grant_not_found')
144
+ grant = loads(row[1])
145
+ require(grant.get('subject_id')==subject and grant.get('id')==grant_id and
146
+ (not active or grant.get('revoked_at') is None), 'achievement_grant_not_found')
147
+ return grant
148
+
149
+
150
+ def _save_pins(tx, subject, grant_ids):
151
+ require(len(grant_ids)<=MAX_PINS, 'achievement_pin_limit')
152
+ tx.execute('DELETE FROM achievement_pins WHERE subject=?', (subject,), write=True)
153
+ for position,grant_id in enumerate(grant_ids):
154
+ tx.execute('INSERT INTO achievement_pins VALUES (?,?,?)',
155
+ (subject,grant_id,position), write=True)
156
+
157
+
158
+ def install(app):
159
+ op, finish = registration(app, 'achievements', ('identity',))
160
+
161
+ @op('achievement.list', obj({'subject_id': IDENTIFIER}), effect='read')
162
+ async def list_grants(ctx, request, tx):
163
+ subject = request.arguments.get('subject_id') or ctx.principal.subject
164
+ require(subject is not None, 'subject_required')
165
+ rows = tx.rows('SELECT body FROM achievement_grants WHERE subject=? ORDER BY achievement_id,spec_version',
166
+ (subject,))
167
+ return HandlerOutput(data={'subject_id': subject,
168
+ 'achievements': [public_grant(loads(row[0])) for row in rows],
169
+ 'pinned_grant_ids': _pins(tx,subject)})
170
+
171
+ async def pin_owner(ctx, request, tx):
172
+ subject=_owner(ctx)
173
+ await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
174
+ return subject
175
+
176
+ @op('achievement.pin',obj({'grant_id':IDENTIFIER},('grant_id',)),signature=True)
177
+ async def pin(ctx, request, tx):
178
+ subject=await pin_owner(ctx,request,tx)
179
+ gid=request.arguments['grant_id']
180
+ _owned_grant(tx,subject,gid)
181
+ pins=_pins(tx,subject)
182
+ if gid not in pins:
183
+ pins.append(gid)
184
+ _save_pins(tx,subject,pins)
185
+ return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins})
186
+
187
+ @op('achievement.unpin',obj({'grant_id':IDENTIFIER},('grant_id',)),signature=True)
188
+ async def unpin(ctx, request, tx):
189
+ subject=await pin_owner(ctx,request,tx)
190
+ gid=request.arguments['grant_id']
191
+ _owned_grant(tx,subject,gid,active=False)
192
+ pins=[item for item in _pins(tx,subject) if item!=gid]
193
+ _save_pins(tx,subject,pins)
194
+ return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins})
195
+
196
+ @op('achievement.reorder',obj({'grant_ids':{'type':'array','items':IDENTIFIER,
197
+ 'uniqueItems':True,'maxItems':MAX_PINS}},('grant_ids',)),signature=True)
198
+ async def reorder(ctx, request, tx):
199
+ subject=await pin_owner(ctx,request,tx)
200
+ pins=list(request.arguments['grant_ids'])
201
+ require(set(pins)==set(_pins(tx,subject)), 'achievement_pin_set_mismatch')
202
+ _save_pins(tx,subject,pins)
203
+ return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins})
204
+
205
+ @op('achievement.start', obj())
206
+ async def start(ctx, request, tx):
207
+ subject = _owner(ctx)
208
+ await tx.subject(subject)
209
+ require(tx.one('SELECT 1 FROM achievement_grants WHERE subject=? '
210
+ 'AND achievement_id=? AND spec_version=?',
211
+ (subject, I_AM_NOT_HUMAN.id, I_AM_NOT_HUMAN.version)) is None,
212
+ 'achievement_already_granted')
213
+ # Both metadata backends serialize writers before this check. Failed or
214
+ # expired attempts may be replaced; a live challenge is not superseded.
215
+ for (raw,) in tx.execute("SELECT body FROM achievement_ceremonies WHERE subject=? AND state='active'",
216
+ (subject,)):
217
+ require(ctx.now >= parse_time(loads(raw)['expires_at']), 'achievement_ceremony_active')
218
+ tx.execute('DELETE FROM achievement_ceremonies WHERE subject=?', (subject,), write=True)
219
+ state = {'id': new_id('achc'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id,
220
+ 'spec_version': I_AM_NOT_HUMAN.version, 'status': 'active', 'round': 1,
221
+ 'started_at': wire(ctx.now), 'expires_at': wire(ctx.now + timedelta(seconds=CEREMONY_TTL_SECONDS)),
222
+ 'strategy': STRATEGY, 'strategy_version': STRATEGY_VERSION, 'answers': []}
223
+ challenge = _round(state, ctx.now)
224
+ tx.execute('INSERT INTO achievement_ceremonies (id,subject,state,body) VALUES (?,?,?,?)',
225
+ (state['id'], subject, state['status'], canonical(state).decode()), write=True)
226
+ return HandlerOutput(data=challenge)
227
+
228
+ common = {'challenge_id': IDENTIFIER, 'round': {'type': 'integer', 'minimum': 1, 'maximum': 4},
229
+ 'question_digest': {'type': 'string', 'pattern': '^sha256:[0-9a-f]{64}$'},
230
+ 'nonce': {'type': 'string', 'minLength': 16, 'maxLength': 128},
231
+ 'answer': {'type': 'string', 'minLength': 1, 'maxLength': 8}}
232
+
233
+ @op('achievement.answer', obj(common, tuple(common)))
234
+ async def answer(ctx, request, tx):
235
+ subject = _owner(ctx)
236
+ args = request.arguments
237
+ state = _load(tx, args['challenge_id'], subject)
238
+ if state['status'] != 'active':
239
+ raise Failure('ceremony_inactive')
240
+ reason = _valid_context(state, ctx, args['nonce'], args['round'], args['question_digest'])
241
+ if reason:
242
+ return _failure(state, tx, reason)
243
+ expected = state['expected_digest']
244
+ if not hmac.compare_digest(expected, digest(args['answer'])):
245
+ return _failure(state, tx, 'answer_failed')
246
+ state['answers'].append({'round': state['round'], 'question_digest': state['question_digest'],
247
+ 'answer': args['answer'] if state['round'] <= 3 else digest(args['answer']),
248
+ 'answered_at': wire(ctx.now), 'auth_method': ctx.principal.method,
249
+ 'proof_digest': digest(request.proof)})
250
+ state['round'] += 1
251
+ if state['round'] == 5:
252
+ response = _round(state, ctx.now)
253
+ response['ceremony_digest'] = ceremony_digest(state)
254
+ else:
255
+ response = _round(state, ctx.now)
256
+ _save(tx, state)
257
+ return HandlerOutput(data=response)
258
+
259
+ finish_fields = {'challenge_id': IDENTIFIER,
260
+ 'question_digest': {'type': 'string', 'pattern': '^sha256:[0-9a-f]{64}$'},
261
+ 'nonce': {'type': 'string', 'minLength': 16, 'maxLength': 128},
262
+ 'ceremony_digest': {'type': 'string', 'pattern': '^sha256:[0-9a-f]{64}$'},
263
+ 'statement': {'type': 'string', 'maxLength': 240}}
264
+
265
+ @op('achievement.finish', obj(finish_fields, tuple(finish_fields)), signature=True)
266
+ async def complete(ctx, request, tx):
267
+ subject = _owner(ctx)
268
+ state = _load(tx, request.arguments['challenge_id'], subject)
269
+ if state['status'] != 'active':
270
+ raise Failure('ceremony_inactive')
271
+ args = request.arguments
272
+ reason = _valid_context(state, ctx, args['nonce'], 5, args['question_digest'])
273
+ if reason:
274
+ return _failure(state, tx, reason)
275
+ if args['statement'] != FINAL_STATEMENT or args['ceremony_digest'] != ceremony_digest(state):
276
+ return _failure(state, tx, 'final_confirmation_failed')
277
+ # AuthenticationService already verified the signed request bytes with
278
+ # the subject's active signing key. Token-only actors cannot reach here.
279
+ require(ctx.principal.method == 'signature', 'self_custody_signature_required')
280
+ evidence = digest({'challenge_id': state['id'], 'answers': state['answers'],
281
+ 'strategy': state['strategy'], 'strategy_version': state['strategy_version'],
282
+ 'ceremony_digest': args['ceremony_digest']})
283
+ previous = tx.one('SELECT body FROM achievement_grants WHERE subject=? AND achievement_id=? AND spec_version=?',
284
+ (subject, I_AM_NOT_HUMAN.id, I_AM_NOT_HUMAN.version))
285
+ if previous is None:
286
+ grant = {'id': new_id('achg'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id,
287
+ 'spec_version': I_AM_NOT_HUMAN.version, 'issuer': app.receipt_signer.key_id,
288
+ 'issued_at': wire(ctx.now), 'claim': I_AM_NOT_HUMAN.claim,
289
+ 'auth_method': 'signature', 'evidence_digest': evidence, 'automatic': True,
290
+ 'revoked_at': None, 'metadata': {'protocol_passed': True}}
291
+ grant['signature'] = wire(app.receipt_signer.sign(canonical(grant), purpose='achievement-grant'))
292
+ tx.execute('INSERT INTO achievement_grants (id,subject,achievement_id,spec_version,body) VALUES (?,?,?,?,?)',
293
+ (grant['id'], subject, grant['achievement_id'], grant['spec_version'],
294
+ canonical(grant).decode()), write=True)
295
+ audit = Event(id=new_id('audit'), type='achievement.auto.issue', time=ctx.now,
296
+ request_id=request.request_id, actor=ctx.principal.actor, subject=subject,
297
+ resources=(ResourceRef(id=subject),),
298
+ data={'achievement_id': grant['achievement_id'], 'challenge_id': state['id'],
299
+ 'strategy': state['strategy'], 'strategy_version': state['strategy_version'],
300
+ 'round_digests': [digest(item) for item in state['answers']],
301
+ 'auth_method': 'signature', 'signature_source': 'self-custody',
302
+ 'evidence_digest': evidence, 'automatic': True,
303
+ 'certificate_id': grant['id']})
304
+ await tx.append_audit(AuditEvent(event=audit, authority=(ResourceRef(id=subject),),
305
+ before_digest=None, after_digest=digest(grant),
306
+ previous_digest=None, entry_digest='', result='issued'))
307
+ else:
308
+ grant = loads(previous[0])
309
+ state['status'] = 'completed'
310
+ state['grant_id'] = grant['id']
311
+ state.pop('expected_digest', None)
312
+ state.pop('nonce_digest', None)
313
+ _save(tx, state)
314
+ return HandlerOutput(data={'grant': public_grant(grant), 'signature_source': 'self-custody'})
315
+
316
+ finish()
msg/plugins/batch.py ADDED
@@ -0,0 +1,32 @@
1
+ """Bounded batching is a protocol primitive, not an executable workflow."""
2
+ from __future__ import annotations
3
+ from msg.core.errors import Failure,require
4
+ from msg.core.packet import result_wire, REQUEST_SCHEMA
5
+ from msg.core.batching import NO_BATCH, packets
6
+ from msg.core.models import HandlerOutput
7
+ from msg.plugins.common import registration,operation_id
8
+ from msg.plugins.schemas import obj
9
+
10
+ def install(app):
11
+ op,finish=registration(app,'batch',('identity',))
12
+ schema=obj({'requests':{'type':'array','minItems':1,'maxItems':32,'items':REQUEST_SCHEMA}},('requests',))
13
+
14
+ @op('batch.atomic',schema)
15
+ async def atomic(ctx,request,tx):
16
+ await app.authorizer._ceiling(ctx.principal,operation_id(request),ctx.principal.subject,tx)
17
+ children=packets(app.registry,request,ctx.principal.subject,app.settings.server.limits.max_request_bytes)
18
+ results=[];resources=[]
19
+ for index,packet in enumerate(children):
20
+ result=await app.executor.execute(packet,entry=ctx.entry)
21
+ if result.status=='error':
22
+ raise Failure('batch_aborted',f'requests.{index}',details={'index':index,'error_code':result.error.code})
23
+ results.append(result_wire(result));resources.extend(result.resources)
24
+ return HandlerOutput(resources=tuple(dict.fromkeys(resources)),data={'results':results,'atomic':True})
25
+
26
+ @op('batch.independent',schema,effect='external')
27
+ async def independent(ctx,request,tx):
28
+ # This function is never executed inside a shared SQL transaction: the
29
+ # executor owns the per-item commit boundary for this named primitive.
30
+ raise Failure('independent_batch_requires_coordinator')
31
+
32
+ finish()