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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Deterministic panels and signed, quorum-bound escrow decisions.
|
|
2
|
+
|
|
3
|
+
A Case never holds a key or balance. A panel can attest only one allocation per
|
|
4
|
+
member/round. The EscrowEngine consumes the current, revalidated Decision once.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
from msg.core.codec import canonical, decode, digest, loads, parse_time, wire
|
|
11
|
+
from msg.core.errors import Failure, require
|
|
12
|
+
from msg.core.models import BlobRef, HandlerOutput, Principal, ResourceRef, Signature
|
|
13
|
+
from msg.market.orders import HASH, view
|
|
14
|
+
from msg.market.policy import contract
|
|
15
|
+
from msg.market.rationale import PINNED_REF
|
|
16
|
+
from msg.market.rationale import verified as verify_rationale
|
|
17
|
+
from msg.plugins.common import check_access, new_id, no_requirements
|
|
18
|
+
from msg.plugins.orders import _row, _subject
|
|
19
|
+
from msg.plugins.schemas import IDENTIFIER, REF, SIGNATURE, obj
|
|
20
|
+
from msg.security.crypto import verify
|
|
21
|
+
|
|
22
|
+
REASONS = ('quality','not_as_described','wrong_ciphertext','delivery_missing',
|
|
23
|
+
'delivery_digest_mismatch','package_missing','package_digest_mismatch',
|
|
24
|
+
'delivery_timeout','other')
|
|
25
|
+
CASE = obj({'case_id': IDENTIFIER}, ('case_id',))
|
|
26
|
+
PROPOSAL = obj({'case_id': IDENTIFIER, 'order_id': IDENTIFIER,
|
|
27
|
+
'round': {'type':'integer','minimum':0,'maximum':1}, 'policy_digest': HASH,
|
|
28
|
+
'outcome': {'enum':['release','refund','split']},
|
|
29
|
+
'refund_minor': {'type':'integer','minimum':0,'maximum':2**63-1},
|
|
30
|
+
'expires_at': {'type':'string','maxLength':40},
|
|
31
|
+
'rationale_ref': PINNED_REF, 'rationale_digest': HASH},
|
|
32
|
+
('case_id','order_id','round','policy_digest','outcome','refund_minor','expires_at',
|
|
33
|
+
'rationale_ref','rationale_digest'))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read(tx, case_id):
|
|
37
|
+
row = tx.one('SELECT body,round,state,deadline FROM arbitration_cases WHERE id=?', (case_id,))
|
|
38
|
+
require(row is not None, 'case_not_found')
|
|
39
|
+
case = loads(row[0])
|
|
40
|
+
case.update(round=row[1], state=row[2], deadline=row[3])
|
|
41
|
+
return case
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _conflict(tx, candidate, case):
|
|
45
|
+
return candidate in {case['buyer'],case['seller']} or tx.one('''SELECT 1
|
|
46
|
+
FROM arbitrator_conflicts WHERE arbitrator=? AND party IN (?,?)''',
|
|
47
|
+
(candidate,case['buyer'],case['seller'])) is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def member_valid(tx, candidate, case):
|
|
51
|
+
epoch = case['policy']['candidate_epochs'].get(candidate)
|
|
52
|
+
row = tx.one('SELECT epoch,active FROM arbitrator_roles WHERE subject=?', (candidate,))
|
|
53
|
+
if not epoch or not row or not row[1] or row[0] != epoch or _conflict(tx, candidate, case):
|
|
54
|
+
return False
|
|
55
|
+
try:
|
|
56
|
+
subject, resource = await tx.subject(candidate), await tx.resource(candidate)
|
|
57
|
+
return not subject.local_only and subject.kind == 'registered' and resource.state == 'active'
|
|
58
|
+
except Failure:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def panel_for(tx, case, round_number):
|
|
63
|
+
eligible = []
|
|
64
|
+
previous = set(case.get('panels', {}).get('0', ())) if round_number else set()
|
|
65
|
+
for candidate in case['policy']['policy']['candidates']:
|
|
66
|
+
if candidate not in previous and await member_valid(tx, candidate, case):
|
|
67
|
+
eligible.append(candidate)
|
|
68
|
+
def score(candidate):
|
|
69
|
+
return digest({'version': 1, 'order_id': case['order_id'],
|
|
70
|
+
'policy_digest': case['policy']['policy_digest'], 'round': round_number,
|
|
71
|
+
'subject': candidate})
|
|
72
|
+
return sorted(eligible, key=lambda candidate: (score(candidate),candidate))[
|
|
73
|
+
:case['policy']['policy']['panel_size']]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def panel_valid(tx, case):
|
|
77
|
+
panel = case['panels'][str(case['round'])]
|
|
78
|
+
return (len(panel) == case['policy']['policy']['panel_size'] and
|
|
79
|
+
all([await member_valid(tx, candidate, case) for candidate in panel]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def owned_case(tx, ctx, case_id, *, parties=False):
|
|
83
|
+
viewer = ctx.principal.subject
|
|
84
|
+
require(viewer is not None and ctx.principal.actor == viewer, 'case_not_found')
|
|
85
|
+
case = _read(tx, case_id)
|
|
86
|
+
if viewer in {case['buyer'],case['seller']}:
|
|
87
|
+
return case
|
|
88
|
+
if not parties and viewer in case['panels'][str(case['round'])] and await member_valid(tx, viewer, case):
|
|
89
|
+
return case
|
|
90
|
+
raise Failure('case_not_found')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def proposal_base(case):
|
|
94
|
+
policy = case['policy']['policy']
|
|
95
|
+
deadline = parse_time(case['round_deadlines'][str(case['round'])])
|
|
96
|
+
return {'case_id': case['id'], 'order_id': case['order_id'], 'round': case['round'],
|
|
97
|
+
'policy_digest': case['policy']['policy_digest'],
|
|
98
|
+
'expires_at': wire(deadline+timedelta(seconds=policy['decision_lifetime_seconds']))}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def proposal_valid(case, proposal, total):
|
|
102
|
+
base = proposal_base(case)
|
|
103
|
+
require(set(proposal) == {*base,'outcome','refund_minor','rationale_ref','rationale_digest'} and
|
|
104
|
+
all(proposal[k] == v for k,v in base.items()), 'decision_context_mismatch')
|
|
105
|
+
refund, outcome = proposal['refund_minor'], proposal['outcome']
|
|
106
|
+
require(type(refund) is int and ((outcome=='release' and refund==0) or
|
|
107
|
+
(outcome=='refund' and refund==total) or (outcome=='split' and 0<refund<total)),
|
|
108
|
+
'decision_amount_mismatch')
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def verify_vote(app, tx, case, vote):
|
|
112
|
+
from msg.workers.effects import current_principal
|
|
113
|
+
principal = decode(Principal, vote['principal'])
|
|
114
|
+
require(principal.subject == vote['arbitrator'] and principal.actor == principal.subject and
|
|
115
|
+
principal.method == 'signature' and await member_valid(tx, principal.subject, case),
|
|
116
|
+
'decision_member_invalid')
|
|
117
|
+
current = await current_principal(app, principal, tx)
|
|
118
|
+
await app.authorizer.require_base(current, 'orders.dispute_vote@1', principal.subject, tx)
|
|
119
|
+
credential = await tx.credential(principal.credential_id)
|
|
120
|
+
signature = decode(Signature, vote['signature'])
|
|
121
|
+
require(signature.key_id == credential.id and credential.kind == 'signing_key',
|
|
122
|
+
'decision_signer_mismatch')
|
|
123
|
+
verify(credential.verifier, canonical(vote['proposal']), signature, purpose='arbitration-decision')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def validate_decision(app, tx, order, decision, now):
|
|
127
|
+
row = tx.one('SELECT body,revoked FROM arbitration_decisions WHERE id=?', (decision['id'],))
|
|
128
|
+
require(row is not None and not row[1] and digest(loads(row[0])) == digest(decision),
|
|
129
|
+
'decision_revoked_or_unknown')
|
|
130
|
+
case = _read(tx, decision['case_id'])
|
|
131
|
+
require(case['order_id'] == order['id'] and case['round'] == decision['round'] and
|
|
132
|
+
case['state'] == 'decided' and order['state'] == 'disputed', 'decision_not_current')
|
|
133
|
+
proposal_valid(case, decision['proposal'], order['total_price_minor'])
|
|
134
|
+
require(decision['refund_minor'] == decision['proposal']['refund_minor'] and
|
|
135
|
+
decision['policy_digest'] == case['policy']['policy_digest'], 'decision_context_mismatch')
|
|
136
|
+
require(parse_time(decision['expires_at']) > now, 'decision_expired')
|
|
137
|
+
require(parse_time(decision['not_before']) <= now, 'appeal_window_open')
|
|
138
|
+
require(await panel_valid(tx, case), 'decision_panel_invalid')
|
|
139
|
+
require(decision['rationale_ref'] == decision['proposal']['rationale_ref'] and
|
|
140
|
+
decision['rationale_digest'] == decision['proposal']['rationale_digest'],
|
|
141
|
+
'decision_rationale_mismatch')
|
|
142
|
+
await verify_rationale(app, tx, case, decision['proposal'])
|
|
143
|
+
quorum = case['policy']['policy']['quorum']
|
|
144
|
+
require(len(decision['voters']) == quorum and len(set(decision['voters'])) == quorum and
|
|
145
|
+
set(decision['voters']) <= set(case['panels'][str(case['round'])]), 'decision_quorum_invalid')
|
|
146
|
+
cast_times = []
|
|
147
|
+
for arbitrator in decision['voters']:
|
|
148
|
+
vote_row = tx.one('SELECT body FROM arbitration_votes WHERE case_id=? AND round=? AND arbitrator=?',
|
|
149
|
+
(case['id'],case['round'],arbitrator))
|
|
150
|
+
require(vote_row is not None, 'decision_quorum_invalid')
|
|
151
|
+
vote = loads(vote_row[0])
|
|
152
|
+
require(vote['proposal'] == decision['proposal'], 'decision_conflicting_vote')
|
|
153
|
+
await verify_vote(app, tx, case, vote)
|
|
154
|
+
cast_times.append(parse_time(vote['cast_at']))
|
|
155
|
+
delay = (case['policy']['policy']['appeal_seconds']
|
|
156
|
+
if case['round']==0 and case['policy']['policy']['allow_appeal'] else 0)
|
|
157
|
+
require(parse_time(decision['not_before']) == max(cast_times)+timedelta(seconds=delay) and
|
|
158
|
+
decision['expires_at'] == decision['proposal']['expires_at'], 'decision_context_mismatch')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def notice(tx, case, state, now):
|
|
162
|
+
# Private inbox references only. No testimony, vote, email or payload escapes.
|
|
163
|
+
for recipient in {case['buyer'],case['seller']}:
|
|
164
|
+
record = {'id': new_id('message'), 'sender': recipient, 'actor': recipient,
|
|
165
|
+
'recipient': recipient, 'resource': wire(ResourceRef(id=recipient)),
|
|
166
|
+
'order_id': case['order_id'], 'case_id': case['id'],
|
|
167
|
+
'time': wire(now), 'state': state, 'source': 'system'}
|
|
168
|
+
tx.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)',
|
|
169
|
+
(record['id'],recipient,recipient,recipient,'case:'+case['id'],canonical(record).decode()), write=True)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def objective_fault(app, tx, order, now):
|
|
173
|
+
locked = contract(tx, order['id'])
|
|
174
|
+
if order['state'] == 'funded':
|
|
175
|
+
deadline = parse_time(order['funded_at']) + timedelta(
|
|
176
|
+
seconds=locked['policy']['policy']['delivery_timeout_seconds'])
|
|
177
|
+
if now >= deadline:
|
|
178
|
+
return 'delivery_timeout'
|
|
179
|
+
if locked['listing']['delivery_mode'] == 'managed_instant':
|
|
180
|
+
from msg.market.delivery import package
|
|
181
|
+
try:
|
|
182
|
+
await package(app, tx, order)
|
|
183
|
+
except Failure as exc:
|
|
184
|
+
if exc.code in {'package_missing','package_digest_mismatch'}:
|
|
185
|
+
return exc.code
|
|
186
|
+
raise
|
|
187
|
+
if order['state'] == 'delivered':
|
|
188
|
+
from msg.market.delivery import verified
|
|
189
|
+
try:
|
|
190
|
+
await verified(app, tx, order)
|
|
191
|
+
except Failure as exc:
|
|
192
|
+
if exc.code == 'delivery_not_found':
|
|
193
|
+
return 'delivery_missing'
|
|
194
|
+
if exc.code in {'delivery_digest_mismatch','package_missing','package_digest_mismatch'}:
|
|
195
|
+
return exc.code
|
|
196
|
+
raise
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def execute(app, tx, case, decision_id, now, actor, request_id):
|
|
201
|
+
from msg.market.escrow import settle
|
|
202
|
+
order = _row(tx, case['order_id'], case['buyer'])
|
|
203
|
+
row = tx.one('SELECT body FROM arbitration_decisions WHERE id=? AND case_id=? AND round=?',
|
|
204
|
+
(decision_id,case['id'],case['round']))
|
|
205
|
+
require(row is not None, 'decision_not_current')
|
|
206
|
+
decision = loads(row[0])
|
|
207
|
+
existing = tx.one('SELECT body FROM order_settlements WHERE order_id=?', (order['id'],))
|
|
208
|
+
if existing:
|
|
209
|
+
settlement = loads(existing[0])
|
|
210
|
+
require(settlement['decision_id'] == decision_id, 'order_already_settled')
|
|
211
|
+
return settlement['receipts']
|
|
212
|
+
receipts = await settle(app, tx, order, now=now, actor=actor, request_id=request_id,
|
|
213
|
+
reason='arbitration_decision', refund_minor=decision['refund_minor'], decision=decision)
|
|
214
|
+
notice(tx, case, 'executed', now)
|
|
215
|
+
return receipts
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def resolve_cases(app, *, limit=100):
|
|
219
|
+
changed, now = [], app.clock()
|
|
220
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
221
|
+
for (case_id,) in tx.rows('''SELECT id FROM arbitration_cases
|
|
222
|
+
WHERE state IN ('open','decided') AND deadline<=? ORDER BY deadline,id LIMIT ?''',
|
|
223
|
+
(wire(now),limit)):
|
|
224
|
+
case = _read(tx, case_id)
|
|
225
|
+
reason = 'quorum_timeout'
|
|
226
|
+
if case['state'] == 'decided':
|
|
227
|
+
row = tx.one('SELECT id FROM arbitration_decisions WHERE case_id=? AND round=?',
|
|
228
|
+
(case_id,case['round']))
|
|
229
|
+
try:
|
|
230
|
+
require(row is not None, 'decision_not_current')
|
|
231
|
+
await execute(app, tx, case, row[0], now, case['buyer'], 'case-resolve:'+case_id)
|
|
232
|
+
changed.append(case_id)
|
|
233
|
+
continue
|
|
234
|
+
except Failure as exc:
|
|
235
|
+
# No unexpected SQL/IO failure is converted into a money rule.
|
|
236
|
+
if exc.code not in {'decision_revoked_or_unknown','decision_expired',
|
|
237
|
+
'decision_panel_invalid','decision_member_invalid','credential_revoked',
|
|
238
|
+
'credential_expired','invalid_signature','permission_denied',
|
|
239
|
+
'credential_ceiling','decision_not_current','decision_quorum_invalid',
|
|
240
|
+
'decision_signer_mismatch','decision_conflicting_vote','decision_context_mismatch',
|
|
241
|
+
'decision_rationale_missing','decision_rationale_mismatch'}:
|
|
242
|
+
raise
|
|
243
|
+
reason = exc.code
|
|
244
|
+
tx.execute("UPDATE arbitration_cases SET state='held' WHERE id=?", (case_id,), write=True)
|
|
245
|
+
tx.set_setting('case_hold:'+case_id, {'reason': reason, 'at': wire(now)})
|
|
246
|
+
notice(tx, case, 'held', now)
|
|
247
|
+
changed.append(case_id)
|
|
248
|
+
return changed
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def install(app, op):
|
|
252
|
+
from msg.market.rationale import install as install_rationale
|
|
253
|
+
install_rationale(app, op)
|
|
254
|
+
|
|
255
|
+
@op('orders.dispute_open', obj({'order_id': IDENTIFIER,
|
|
256
|
+
'reason': {'enum': list(REASONS)}}, ('order_id','reason')), signature=True)
|
|
257
|
+
async def open_case(ctx, request, tx):
|
|
258
|
+
from msg.market.escrow import settle, transition
|
|
259
|
+
actor = _subject(ctx)
|
|
260
|
+
order = _row(tx, request.arguments['order_id'], actor)
|
|
261
|
+
locked = contract(tx, order['id'])
|
|
262
|
+
existing = tx.one('SELECT id FROM arbitration_cases WHERE order_id=?', (order['id'],))
|
|
263
|
+
if existing:
|
|
264
|
+
return HandlerOutput(data={'case_id': existing[0], 'order_id': order['id']})
|
|
265
|
+
require(order['state'] in {'funded','delivered'}, 'order_not_disputable')
|
|
266
|
+
objective = await objective_fault(app, tx, order, ctx.now)
|
|
267
|
+
if objective:
|
|
268
|
+
receipts = await settle(app, tx, order, now=ctx.now, actor=actor,
|
|
269
|
+
request_id=request.request_id, reason=objective, refund_minor=order['total_price_minor'])
|
|
270
|
+
return HandlerOutput(data={'resolution': 'objective_refund', 'reason': objective,
|
|
271
|
+
'order': view(tx, order, actor), 'receipts': receipts})
|
|
272
|
+
case_id = new_id('case')
|
|
273
|
+
deadline = wire(ctx.now+timedelta(seconds=locked['policy']['policy']['case_timeout_seconds']))
|
|
274
|
+
case = {'id': case_id, 'order_id': order['id'], 'buyer': order['buyer'],
|
|
275
|
+
'seller': order['seller'], 'opened_by': actor, 'opened_at': wire(ctx.now),
|
|
276
|
+
'reason': request.arguments['reason'], 'policy': locked['policy'],
|
|
277
|
+
'panels': {}, 'round_deadlines': {'0': deadline}, 'round': 0}
|
|
278
|
+
case['panels']['0'] = await panel_for(tx, case, 0)
|
|
279
|
+
state = 'open' if await panel_valid(tx, case) else 'held'
|
|
280
|
+
tx.execute('INSERT INTO arbitration_cases(id,order_id,round,state,deadline,body) VALUES (?,?,0,?,?,?)',
|
|
281
|
+
(case_id,order['id'],state,deadline,canonical(case).decode()), write=True)
|
|
282
|
+
await transition(tx, order, 'disputed', now=ctx.now, actor=actor,
|
|
283
|
+
request_id=request.request_id, reason=request.arguments['reason'])
|
|
284
|
+
notice(tx, case, state, ctx.now)
|
|
285
|
+
return HandlerOutput(data={'case_id': case_id, 'order_id': order['id'], 'state': state,
|
|
286
|
+
'panel': case['panels']['0'], 'policy_digest': locked['policy']['policy_digest'],
|
|
287
|
+
'deadline': deadline})
|
|
288
|
+
|
|
289
|
+
@op('orders.dispute_get', CASE, effect='read')
|
|
290
|
+
async def get(ctx, request, tx):
|
|
291
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'])
|
|
292
|
+
viewer = ctx.principal.subject
|
|
293
|
+
panel = viewer in case['panels'][str(case['round'])]
|
|
294
|
+
evidence = []
|
|
295
|
+
for eid,author,visibility,body in tx.rows('''SELECT id,author,visibility,body
|
|
296
|
+
FROM arbitration_evidence WHERE case_id=? ORDER BY id''', (case['id'],)):
|
|
297
|
+
if panel or viewer == author or visibility == 'parties':
|
|
298
|
+
record = loads(body)
|
|
299
|
+
evidence.append({'id': eid,'author': author,'visibility': visibility,
|
|
300
|
+
'kind': record['kind'],'round': record['round'],
|
|
301
|
+
'digest': record['blob']['digest'],'size': record['blob']['size'],
|
|
302
|
+
**({'rationale_ref': record['source']} if record['kind']=='rationale' else {})})
|
|
303
|
+
decisions = [loads(row[0]) for row in tx.rows(
|
|
304
|
+
'SELECT body FROM arbitration_decisions WHERE case_id=? ORDER BY round', (case['id'],))]
|
|
305
|
+
votes = [loads(row[0]) for row in tx.rows(
|
|
306
|
+
'SELECT body FROM arbitration_votes WHERE case_id=? AND round=? ORDER BY arbitrator',
|
|
307
|
+
(case['id'],case['round']))]
|
|
308
|
+
return HandlerOutput(data={'case': {k:v for k,v in case.items() if k!='policy'},
|
|
309
|
+
'policy': case['policy'], 'panel_valid': await panel_valid(tx, case),
|
|
310
|
+
'proposal_base': proposal_base(case), 'evidence': evidence,
|
|
311
|
+
'votes': [{'arbitrator':v['arbitrator'],'proposal':v['proposal'],'signature':v['signature']}
|
|
312
|
+
for v in votes], 'decisions': decisions})
|
|
313
|
+
|
|
314
|
+
@op('orders.dispute_summary', CASE, effect='read', requirements=no_requirements)
|
|
315
|
+
async def summary(ctx, request, tx):
|
|
316
|
+
case = _read(tx, request.arguments['case_id'])
|
|
317
|
+
row = tx.one('SELECT body FROM arbitration_decisions WHERE case_id=? AND round=?',
|
|
318
|
+
(case['id'],case['round']))
|
|
319
|
+
return HandlerOutput(data={'case_id': case['id'], 'state': case['state'],
|
|
320
|
+
'policy_digest': case['policy']['policy_digest'],
|
|
321
|
+
'outcome': loads(row[0])['proposal']['outcome'] if row and case['state']=='executed' else None})
|
|
322
|
+
|
|
323
|
+
evidence_fields = {'case_id': IDENTIFIER, 'visibility': {'enum':['panel','parties']}}
|
|
324
|
+
@op('orders.dispute_statement', obj({**evidence_fields,
|
|
325
|
+
'text': {'type':'string','minLength':1,'maxLength':4096}}, ('case_id','visibility','text')),
|
|
326
|
+
signature=True)
|
|
327
|
+
@op('orders.dispute_evidence', obj({**evidence_fields, 'ref': REF},
|
|
328
|
+
('case_id','visibility','ref')), signature=True)
|
|
329
|
+
async def add_evidence(ctx, request, tx):
|
|
330
|
+
actor = _subject(ctx)
|
|
331
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'], parties=True)
|
|
332
|
+
require(case['state'] in {'open','held'} and
|
|
333
|
+
ctx.now < parse_time(case['round_deadlines'][str(case['round'])]), 'case_evidence_closed')
|
|
334
|
+
require(tx.one('SELECT COUNT(*) FROM arbitration_evidence WHERE case_id=? AND author=?',
|
|
335
|
+
(case['id'],actor))[0] < 64, 'case_evidence_limit')
|
|
336
|
+
from msg.market.delivery import verify_blob
|
|
337
|
+
if 'text' in request.arguments:
|
|
338
|
+
blob = await app.contents.put_bytes(request.arguments['text'].encode(), 'text/plain')
|
|
339
|
+
kind, source = 'statement', None
|
|
340
|
+
else:
|
|
341
|
+
ref = decode(ResourceRef, request.arguments['ref'])
|
|
342
|
+
require(ref.revision is not None, 'payload_revision_required')
|
|
343
|
+
resource = await tx.resource(ref.id)
|
|
344
|
+
require(resource.owner == actor and resource.type in {'file','attachment'} and
|
|
345
|
+
resource.state == 'active', 'evidence_not_owned')
|
|
346
|
+
await check_access(app, ctx, request, tx, ref.id, 'read')
|
|
347
|
+
blob = (await tx.revision(ref)).content
|
|
348
|
+
kind, source = 'resource', wire(ref)
|
|
349
|
+
await verify_blob(app, blob)
|
|
350
|
+
eid = new_id('evidence')
|
|
351
|
+
await app.contents.pin(blob, eid)
|
|
352
|
+
record = {'kind': kind, 'blob': wire(blob), 'source': source, 'round': case['round'],
|
|
353
|
+
'at': wire(ctx.now), 'author_request_digest': request.payload_digest}
|
|
354
|
+
tx.execute('INSERT INTO arbitration_evidence(id,case_id,author,visibility,body) VALUES (?,?,?,?,?)',
|
|
355
|
+
(eid,case['id'],actor,request.arguments['visibility'],canonical(record).decode()), write=True)
|
|
356
|
+
return HandlerOutput(data={'evidence_id': eid, 'digest': blob.digest})
|
|
357
|
+
|
|
358
|
+
@op('orders.dispute_evidence_get', obj({'case_id': IDENTIFIER,'evidence_id': IDENTIFIER,
|
|
359
|
+
'offset': {'type':'integer','minimum':0},
|
|
360
|
+
'length': {'type':'integer','minimum':1,'maximum':65536}},
|
|
361
|
+
('case_id','evidence_id')), effect='read')
|
|
362
|
+
async def evidence_get(ctx, request, tx):
|
|
363
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'])
|
|
364
|
+
row = tx.one('SELECT author,visibility,body FROM arbitration_evidence WHERE id=? AND case_id=?',
|
|
365
|
+
(request.arguments['evidence_id'],case['id']))
|
|
366
|
+
viewer = ctx.principal.subject
|
|
367
|
+
require(row is not None and (viewer==row[0] or row[1]=='parties' or
|
|
368
|
+
viewer in case['panels'][str(case['round'])]), 'evidence_not_found')
|
|
369
|
+
record = loads(row[2])
|
|
370
|
+
blob = decode(BlobRef, record['blob'])
|
|
371
|
+
start = request.arguments.get('offset', 0)
|
|
372
|
+
end = min(blob.size, start+request.arguments.get('length', 65536))
|
|
373
|
+
require(0 <= start <= end <= blob.size, 'invalid_byte_range')
|
|
374
|
+
raw = b''.join([part async for part in app.contents.read(blob,(start,end))])
|
|
375
|
+
from msg.core.codec import b64
|
|
376
|
+
return HandlerOutput(data={'evidence_id': request.arguments['evidence_id'], 'digest': blob.digest,
|
|
377
|
+
'size': blob.size, 'offset': start, 'data': b64(raw), 'chunk_digest': digest(raw),
|
|
378
|
+
'next_offset': end if end<blob.size else None})
|
|
379
|
+
|
|
380
|
+
@op('orders.dispute_vote', obj({'proposal': PROPOSAL, 'signature': SIGNATURE},
|
|
381
|
+
('proposal','signature')), signature=True)
|
|
382
|
+
async def vote(ctx, request, tx):
|
|
383
|
+
actor = _subject(ctx)
|
|
384
|
+
proposal = wire(request.arguments['proposal'])
|
|
385
|
+
case = await owned_case(tx, ctx, proposal['case_id'])
|
|
386
|
+
require(actor in case['panels'][str(case['round'])] and await panel_valid(tx, case),
|
|
387
|
+
'arbitrator_required')
|
|
388
|
+
require(case['state']=='open' and
|
|
389
|
+
ctx.now < parse_time(case['round_deadlines'][str(case['round'])]), 'case_voting_closed')
|
|
390
|
+
order = _row(tx, case['order_id'], case['buyer'])
|
|
391
|
+
proposal_valid(case, proposal, order['total_price_minor'])
|
|
392
|
+
await verify_rationale(app, tx, case, proposal)
|
|
393
|
+
record = {'arbitrator': actor, 'proposal': proposal, 'signature': wire(request.arguments['signature']),
|
|
394
|
+
'principal': wire(ctx.principal), 'cast_at': wire(ctx.now)}
|
|
395
|
+
await verify_vote(app, tx, case, record)
|
|
396
|
+
previous = tx.one('SELECT body FROM arbitration_votes WHERE case_id=? AND round=? AND arbitrator=?',
|
|
397
|
+
(case['id'],case['round'],actor))
|
|
398
|
+
if previous:
|
|
399
|
+
require(loads(previous[0])['proposal'] == proposal, 'conflicting_vote')
|
|
400
|
+
else:
|
|
401
|
+
tx.execute('INSERT INTO arbitration_votes(case_id,round,arbitrator,body) VALUES (?,?,?,?)',
|
|
402
|
+
(case['id'],case['round'],actor,canonical(record).decode()), write=True)
|
|
403
|
+
votes = [loads(r[0]) for r in tx.rows('SELECT body FROM arbitration_votes WHERE case_id=? AND round=?',
|
|
404
|
+
(case['id'],case['round']))]
|
|
405
|
+
matching = []
|
|
406
|
+
for cast in votes:
|
|
407
|
+
if cast['proposal'] == proposal:
|
|
408
|
+
await verify_vote(app, tx, case, cast)
|
|
409
|
+
matching.append(cast)
|
|
410
|
+
quorum = case['policy']['policy']['quorum']
|
|
411
|
+
decision = None
|
|
412
|
+
if len(matching) >= quorum:
|
|
413
|
+
voters = sorted(matching, key=lambda v:v['arbitrator'])[:quorum]
|
|
414
|
+
delay = (case['policy']['policy']['appeal_seconds']
|
|
415
|
+
if case['round']==0 and case['policy']['policy']['allow_appeal'] else 0)
|
|
416
|
+
decision = {'id': 'dec_'+digest(proposal)[7:39], 'case_id': case['id'],
|
|
417
|
+
'order_id': order['id'], 'round': case['round'], 'proposal': proposal,
|
|
418
|
+
'refund_minor': proposal['refund_minor'], 'policy_digest': proposal['policy_digest'],
|
|
419
|
+
'rationale_ref': proposal['rationale_ref'], 'rationale_digest': proposal['rationale_digest'],
|
|
420
|
+
'voters': [v['arbitrator'] for v in voters], 'expires_at': proposal['expires_at'],
|
|
421
|
+
'not_before': wire(max(parse_time(v['cast_at']) for v in voters)+timedelta(seconds=delay))}
|
|
422
|
+
tx.execute('INSERT INTO arbitration_decisions(id,case_id,round,body) VALUES (?,?,?,?)',
|
|
423
|
+
(decision['id'],case['id'],case['round'],canonical(decision).decode()), write=True)
|
|
424
|
+
tx.execute("UPDATE arbitration_cases SET state='decided',deadline=? WHERE id=?",
|
|
425
|
+
(decision['not_before'],case['id']), write=True)
|
|
426
|
+
notice(tx, case, 'decided', ctx.now)
|
|
427
|
+
return HandlerOutput(data={'case_id': case['id'], 'votes_for_proposal': len(matching), 'decision': decision})
|
|
428
|
+
|
|
429
|
+
@op('orders.dispute_execute', obj({'case_id': IDENTIFIER,'decision_id': IDENTIFIER},
|
|
430
|
+
('case_id','decision_id')), signature=True)
|
|
431
|
+
async def execute_decision(ctx, request, tx):
|
|
432
|
+
actor = _subject(ctx)
|
|
433
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'])
|
|
434
|
+
receipts = await execute(app, tx, case, request.arguments['decision_id'], ctx.now, actor, request.request_id)
|
|
435
|
+
return HandlerOutput(data={'case_id': case['id'],'state':'executed','receipts':receipts})
|
|
436
|
+
|
|
437
|
+
@op('orders.dispute_appeal', CASE, signature=True)
|
|
438
|
+
async def appeal(ctx, request, tx):
|
|
439
|
+
_subject(ctx)
|
|
440
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'], parties=True)
|
|
441
|
+
policy = case['policy']['policy']
|
|
442
|
+
require(policy['allow_appeal'] and case['round']==0 and case['state'] in {'decided','held'},
|
|
443
|
+
'appeal_not_available')
|
|
444
|
+
row = tx.one('SELECT body FROM arbitration_decisions WHERE case_id=? AND round=0', (case['id'],))
|
|
445
|
+
until = (parse_time(loads(row[0])['not_before']) if row else
|
|
446
|
+
parse_time(case['round_deadlines']['0'])+timedelta(seconds=policy['appeal_seconds']))
|
|
447
|
+
require(ctx.now < until, 'appeal_window_closed')
|
|
448
|
+
case['panels']['1'] = await panel_for(tx, case, 1)
|
|
449
|
+
case['round'] = 1
|
|
450
|
+
deadline = wire(ctx.now+timedelta(seconds=policy['case_timeout_seconds']))
|
|
451
|
+
case['round_deadlines']['1'] = deadline
|
|
452
|
+
state = 'open' if await panel_valid(tx, case) else 'held'
|
|
453
|
+
tx.execute('UPDATE arbitration_cases SET round=1,state=?,deadline=?,body=? WHERE id=?',
|
|
454
|
+
(state,deadline,canonical(case).decode(),case['id']), write=True)
|
|
455
|
+
notice(tx, case, 'appealed', ctx.now)
|
|
456
|
+
return HandlerOutput(data={'case_id':case['id'],'round':1,'state':state,'panel':case['panels']['1']})
|
|
457
|
+
|
|
458
|
+
@op('orders.dispute_revoke', obj({'case_id': IDENTIFIER,'decision_id': IDENTIFIER},
|
|
459
|
+
('case_id','decision_id')), signature=True)
|
|
460
|
+
async def revoke_decision(ctx, request, tx):
|
|
461
|
+
actor = _subject(ctx)
|
|
462
|
+
case = await owned_case(tx, ctx, request.arguments['case_id'])
|
|
463
|
+
row = tx.one('SELECT body FROM arbitration_decisions WHERE id=? AND case_id=? AND round=?',
|
|
464
|
+
(request.arguments['decision_id'],case['id'],case['round']))
|
|
465
|
+
require(row is not None and actor in loads(row[0])['voters'], 'decision_signer_required')
|
|
466
|
+
require(case['state'] in {'decided','held'}, 'decision_already_executed')
|
|
467
|
+
tx.execute('UPDATE arbitration_decisions SET revoked=TRUE WHERE id=?',
|
|
468
|
+
(request.arguments['decision_id'],), write=True)
|
|
469
|
+
tx.execute("UPDATE arbitration_cases SET state='held' WHERE id=?", (case['id'],), write=True)
|
|
470
|
+
notice(tx, case, 'held', ctx.now)
|
|
471
|
+
return HandlerOutput(data={'case_id':case['id'],'state':'held','decision_revoked':True})
|
|
472
|
+
|
|
473
|
+
@op('orders.arbitrator_conflict', obj({'party': IDENTIFIER}, ('party',)), signature=True)
|
|
474
|
+
async def declare_conflict(ctx, request, tx):
|
|
475
|
+
actor = _subject(ctx)
|
|
476
|
+
role = tx.one('SELECT active FROM arbitrator_roles WHERE subject=?', (actor,))
|
|
477
|
+
require(role is not None and role[0], 'arbitrator_required')
|
|
478
|
+
party = (await tx.subject(request.arguments['party'])).resource_id
|
|
479
|
+
tx.execute('INSERT INTO arbitrator_conflicts(arbitrator,party) VALUES (?,?) ON CONFLICT DO NOTHING',
|
|
480
|
+
(actor,party), write=True)
|
|
481
|
+
return HandlerOutput(data={'declared':True})
|