provared 0.2.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.
- provared/__init__.py +60 -0
- provared/_js.py +450 -0
- provared/actions.py +84 -0
- provared/approval.py +108 -0
- provared/blockstamp.py +223 -0
- provared/book.py +2320 -0
- provared/check.py +21 -0
- provared/compare.py +266 -0
- provared/cover.py +187 -0
- provared/der.py +230 -0
- provared/encoding.py +278 -0
- provared/fields.py +95 -0
- provared/guard.py +177 -0
- provared/headers.py +507 -0
- provared/jws.py +164 -0
- provared/keys.py +134 -0
- provared/pass_.py +86 -0
- provared/recorder.py +1426 -0
- provared/seal.py +133 -0
- provared/service.py +138 -0
- provared/signatures.py +149 -0
- provared/slip.py +413 -0
- provared/standing.py +198 -0
- provared/stub.py +131 -0
- provared/timestamp.py +456 -0
- provared/tools.py +415 -0
- provared/tree.py +148 -0
- provared/webauthn.py +107 -0
- provared-0.2.0.dist-info/METADATA +176 -0
- provared-0.2.0.dist-info/RECORD +34 -0
- provared-0.2.0.dist-info/WHEEL +5 -0
- provared-0.2.0.dist-info/licenses/LICENSE +202 -0
- provared-0.2.0.dist-info/licenses/LICENSE-CC-BY-4.0.txt +396 -0
- provared-0.2.0.dist-info/top_level.txt +1 -0
provared/recorder.py
ADDED
|
@@ -0,0 +1,1426 @@
|
|
|
1
|
+
# The stub writer beside an agent. It keeps the agent's place in its chain,
|
|
2
|
+
# asks the check before acting (guard.py) before each action, and writes a
|
|
3
|
+
# stub for each action that is taken. It holds the book as text; where the
|
|
4
|
+
# text is kept is the caller's business.
|
|
5
|
+
#
|
|
6
|
+
# It makes the two things an agent's software must do into one call, so
|
|
7
|
+
# that they are not forgotten: ask first, and write the receipt after.
|
|
8
|
+
#
|
|
9
|
+
# One writer serves one chain: an agent under its slip, or a helper under
|
|
10
|
+
# its pass. Its calls wait for one another, so two actions asked for at the
|
|
11
|
+
# same moment (from two threads) are taken one after the other, each
|
|
12
|
+
# against the book as the one before left it. Two writers on copies of one
|
|
13
|
+
# book know nothing of each other: keeping them apart is the caller's
|
|
14
|
+
# business.
|
|
15
|
+
#
|
|
16
|
+
# The writer never puts into its book a line that would make the book fail
|
|
17
|
+
# its check. Before an action is taken, its stub is written and checked
|
|
18
|
+
# with the whole book; what the other side hands back is checked before it
|
|
19
|
+
# joins the stub, and left out if it does not check.
|
|
20
|
+
#
|
|
21
|
+
# The book is read once, when the writer is opened, and is then carried on
|
|
22
|
+
# (book.py): each new line is read by itself, against what was worked out
|
|
23
|
+
# from the lines before it. So no signature is checked twice, and what an
|
|
24
|
+
# action costs grows only slowly with the length of the book. Every line
|
|
25
|
+
# is tried on a reader split off the book's own, which takes the book's
|
|
26
|
+
# place only if the line is written, and is thrown away otherwise. So the
|
|
27
|
+
# book's own reader always stands for exactly the text of the book,
|
|
28
|
+
# whatever fails on the way.
|
|
29
|
+
#
|
|
30
|
+
# What is handed to the writer is copied when it is handed over: the
|
|
31
|
+
# request, the approval, what the other side hands back. The copy is what
|
|
32
|
+
# is checked and what is written, so a later change to the caller's own
|
|
33
|
+
# objects changes nothing.
|
|
34
|
+
#
|
|
35
|
+
# This is the Python version of the JavaScript library's src/recorder.js,
|
|
36
|
+
# ported one to one. Where JavaScript waits for a promise, this waits in
|
|
37
|
+
# the same thread: every call is an ordinary function. The other side's
|
|
38
|
+
# countersignature is asked for in a thread of its own, so that the time
|
|
39
|
+
# it is given can run out.
|
|
40
|
+
|
|
41
|
+
import concurrent.futures
|
|
42
|
+
import contextvars
|
|
43
|
+
import copy
|
|
44
|
+
import datetime
|
|
45
|
+
import inspect
|
|
46
|
+
import math
|
|
47
|
+
import re
|
|
48
|
+
import threading
|
|
49
|
+
from collections.abc import Mapping
|
|
50
|
+
|
|
51
|
+
from . import _js
|
|
52
|
+
from .book import _SNAKE, MAX_LINE_BYTES, answer_of, carry_on, check_countersignature, entry_line, fixed_options, fork_carried, latest_when, promote_carried, start_carried
|
|
53
|
+
from .encoding import Refusal, canonical_json, fingerprint, from_base64url, nan_time, now_ms, problem_from, utf8
|
|
54
|
+
from .guard import check_before_read
|
|
55
|
+
from .seal import CLOCK_ALLOWANCE_MS, MAX_STAMPS
|
|
56
|
+
from .standing import write_acknowledgement
|
|
57
|
+
from .stub import write_stub
|
|
58
|
+
|
|
59
|
+
COUNTERSIGN_WITHIN_MS = 30000
|
|
60
|
+
"""How long the other side is given to hand back its countersignature, unless the caller says otherwise: 30 seconds."""
|
|
61
|
+
|
|
62
|
+
# The longest wait a timer can hold in JavaScript, kept here so that both accept the same numbers.
|
|
63
|
+
_MAX_WAIT_MS = 2**31 - 1
|
|
64
|
+
# The most cancellations a check may be handed beside a book (book.py).
|
|
65
|
+
_MAX_HANDED = 16
|
|
66
|
+
# The deepest a copied value may nest: far more than a line of a book can hold.
|
|
67
|
+
_MAX_COPY_DEPTH = 64
|
|
68
|
+
|
|
69
|
+
_INSIDE_MESSAGE = 'The stub writer was called from inside an action it is taking. Call it once the action has ended.'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Absent:
|
|
73
|
+
"""What JavaScript calls undefined: a member that was not given. It is
|
|
74
|
+
kept apart from None, which stands for null."""
|
|
75
|
+
|
|
76
|
+
__slots__ = ()
|
|
77
|
+
|
|
78
|
+
def __repr__(self):
|
|
79
|
+
return 'ABSENT'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_ABSENT = _Absent()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _member(value, name):
|
|
86
|
+
"""value[name] as JavaScript reads it from plain data: the member of an
|
|
87
|
+
object, or nothing (undefined) for anything else."""
|
|
88
|
+
if isinstance(value, dict) and name in value:
|
|
89
|
+
return value[name]
|
|
90
|
+
return _ABSENT
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _same(items, value):
|
|
94
|
+
"""Whether a list holds this very object (JavaScript's includes, for objects)."""
|
|
95
|
+
return any(x is value for x in items)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Where the platform can tell, a call to the writer made by an action that
|
|
99
|
+
# the writer is taking is refused at once. It would otherwise wait for the
|
|
100
|
+
# action to end, while the action waits for it. The mark is carried by the
|
|
101
|
+
# context, so it reaches the action's own code, and a thread started with
|
|
102
|
+
# a copy of that context.
|
|
103
|
+
_INSIDE = contextvars.ContextVar('provared_recorder_inside', default=None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Mark:
|
|
107
|
+
__slots__ = ('writer', 'running')
|
|
108
|
+
|
|
109
|
+
def __init__(self, writer):
|
|
110
|
+
self.writer = writer
|
|
111
|
+
self.running = True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _not_written(why):
|
|
115
|
+
return Refusal('record-not-sound', why)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _refuse_awaitable(value, why):
|
|
119
|
+
"""A function handed to the writer gave a coroutine or another
|
|
120
|
+
awaitable. This library takes no part in asyncio: the awaitable is
|
|
121
|
+
closed, unrun, and refused."""
|
|
122
|
+
close = getattr(value, 'close', None)
|
|
123
|
+
if callable(close):
|
|
124
|
+
try:
|
|
125
|
+
close()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
raise TypeError(why)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- copies ---
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Stands in the list of an entry's members for a name that is not plain data.
|
|
135
|
+
_STRANGE = object()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _scalar(value):
|
|
139
|
+
return value is None or isinstance(value, (bool, int, float, str))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _clone(value, most=_MAX_COPY_DEPTH, text_names=False):
|
|
143
|
+
"""A copy that nothing else holds, as structuredClone makes one, for plain
|
|
144
|
+
data: objects (dicts, with text for names), lists, text, numbers, true,
|
|
145
|
+
false and null. A value met twice is copied once. Raises TypeError for
|
|
146
|
+
anything else (a function, an object of a class, bytes), and for a
|
|
147
|
+
value that holds itself. It walks the value without recursion, so a
|
|
148
|
+
deep value costs no stack. A name of a dict that is not text is taken
|
|
149
|
+
as JavaScript would write it, or, with text_names, refused."""
|
|
150
|
+
if _scalar(value) or value is _ABSENT:
|
|
151
|
+
return value
|
|
152
|
+
if not isinstance(value, (Mapping, list)):
|
|
153
|
+
raise TypeError('not plain data')
|
|
154
|
+
|
|
155
|
+
def fresh(v):
|
|
156
|
+
if isinstance(v, Mapping):
|
|
157
|
+
if text_names and not all(isinstance(k, str) for k in v):
|
|
158
|
+
raise TypeError('a name that is not text')
|
|
159
|
+
return {}, iter([(_js.property_key(k), item) for k, item in v.items()])
|
|
160
|
+
return [], iter(list(v))
|
|
161
|
+
|
|
162
|
+
memo = {}
|
|
163
|
+
active = set()
|
|
164
|
+
keep = [value]
|
|
165
|
+
root, items = fresh(value)
|
|
166
|
+
memo[id(value)] = root
|
|
167
|
+
active.add(id(value))
|
|
168
|
+
stack = [(value, root, items, 0)]
|
|
169
|
+
while stack:
|
|
170
|
+
source, made, items, depth = stack[-1]
|
|
171
|
+
try:
|
|
172
|
+
item = next(items)
|
|
173
|
+
except StopIteration:
|
|
174
|
+
stack.pop()
|
|
175
|
+
active.discard(id(source))
|
|
176
|
+
continue
|
|
177
|
+
name, v = item if isinstance(made, dict) else (None, item)
|
|
178
|
+
if _scalar(v):
|
|
179
|
+
c = v
|
|
180
|
+
elif isinstance(v, (Mapping, list)):
|
|
181
|
+
if id(v) in active:
|
|
182
|
+
raise TypeError('the value holds itself')
|
|
183
|
+
c = memo.get(id(v))
|
|
184
|
+
if c is None:
|
|
185
|
+
# As deep as a copy may go: far more than a record holds. (The
|
|
186
|
+
# connector for tools copies without a limit, as the JavaScript
|
|
187
|
+
# one does, and refuses what nests too deeply itself.)
|
|
188
|
+
if most is not None and depth + 1 > most:
|
|
189
|
+
raise TypeError('the value nests too deeply')
|
|
190
|
+
c, inner = fresh(v)
|
|
191
|
+
memo[id(v)] = c
|
|
192
|
+
active.add(id(v))
|
|
193
|
+
keep.append(v)
|
|
194
|
+
stack.append((v, c, inner, depth + 1))
|
|
195
|
+
else:
|
|
196
|
+
raise TypeError('not plain data')
|
|
197
|
+
if isinstance(made, dict):
|
|
198
|
+
made[name] = c
|
|
199
|
+
else:
|
|
200
|
+
made.append(c)
|
|
201
|
+
return root
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _plain(value, what):
|
|
205
|
+
"""A copy that nothing else holds."""
|
|
206
|
+
try:
|
|
207
|
+
return _clone(value)
|
|
208
|
+
except Exception:
|
|
209
|
+
raise Refusal('bad-field', f'{what} must be plain data: text, numbers, lists and objects.') from None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _TooMuch(Exception):
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _copier():
|
|
217
|
+
"""A copy that nothing else holds, made in one pass: each member and each
|
|
218
|
+
place is read once, and copied as it is read. A value met twice is
|
|
219
|
+
copied once. Raises where it cannot be done: anything but plain data,
|
|
220
|
+
a value that holds itself (which nests without end), or a value that,
|
|
221
|
+
written out, would be longer than a line of a book may be.
|
|
222
|
+
|
|
223
|
+
The copies one copier makes share one count, so that several values,
|
|
224
|
+
each short enough for a line, cannot together cost more than a line."""
|
|
225
|
+
copies = {}
|
|
226
|
+
keep = []
|
|
227
|
+
total = 0
|
|
228
|
+
|
|
229
|
+
# The copy, and the fewest characters it takes written out: a text its
|
|
230
|
+
# characters and two quotation marks; any other single value one; a list
|
|
231
|
+
# or an object its brackets and commas, and each member's name with its
|
|
232
|
+
# quotation marks and colon. A value met again is counted again, as it is
|
|
233
|
+
# written out again.
|
|
234
|
+
def walk(v, depth):
|
|
235
|
+
if isinstance(v, str):
|
|
236
|
+
return v, _js.utf16_length(v) + 2
|
|
237
|
+
if v is None or isinstance(v, (bool, int, float)):
|
|
238
|
+
return v, 1
|
|
239
|
+
if not isinstance(v, (Mapping, list)):
|
|
240
|
+
raise _TooMuch('not plain data')
|
|
241
|
+
found = copies.get(id(v))
|
|
242
|
+
if found is not None:
|
|
243
|
+
return found
|
|
244
|
+
if depth > _MAX_COPY_DEPTH:
|
|
245
|
+
raise _TooMuch('the value nests too deeply, or holds itself')
|
|
246
|
+
is_list = isinstance(v, list)
|
|
247
|
+
if not is_list:
|
|
248
|
+
try:
|
|
249
|
+
v = {_js.property_key(k): item for k, item in v.items()}
|
|
250
|
+
except TypeError:
|
|
251
|
+
raise _TooMuch('not plain data') from None
|
|
252
|
+
made = [] if is_list else {}
|
|
253
|
+
size = 2
|
|
254
|
+
members = 0
|
|
255
|
+
for name, value in (enumerate(list(v)) if is_list else list(v.items())):
|
|
256
|
+
item, count = walk(value, depth + 1)
|
|
257
|
+
size += count + (1 if members > 0 else 0) + (0 if is_list else _js.utf16_length(name) + 3)
|
|
258
|
+
members += 1
|
|
259
|
+
if size > MAX_LINE_BYTES:
|
|
260
|
+
raise _TooMuch('more than a line can hold')
|
|
261
|
+
if is_list:
|
|
262
|
+
made.append(item)
|
|
263
|
+
else:
|
|
264
|
+
made[name] = item
|
|
265
|
+
copies[id(v)] = (made, size)
|
|
266
|
+
keep.append(v)
|
|
267
|
+
return made, size
|
|
268
|
+
|
|
269
|
+
def copy_one(value):
|
|
270
|
+
nonlocal total
|
|
271
|
+
made, size = walk(value, 0)
|
|
272
|
+
total += size
|
|
273
|
+
if total > MAX_LINE_BYTES:
|
|
274
|
+
raise _TooMuch('more than a line can hold')
|
|
275
|
+
return made
|
|
276
|
+
|
|
277
|
+
return copy_one
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _copy_of(value):
|
|
281
|
+
return _copier()(value)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _plain_held(held):
|
|
285
|
+
"""A copy of a cancellation given when the writer is opened, as plain
|
|
286
|
+
data: its three members, and any other member, each copied. So what
|
|
287
|
+
"cancellations" hands back is what was checked, and the writer never
|
|
288
|
+
changes the caller's own object. One that cannot be copied so is
|
|
289
|
+
refused when the writer is opened. Each of the three may be as long as
|
|
290
|
+
a line; the other members share one line's length."""
|
|
291
|
+
if not isinstance(held, dict):
|
|
292
|
+
return held
|
|
293
|
+
try:
|
|
294
|
+
made = {}
|
|
295
|
+
rest = _copier()
|
|
296
|
+
for name, value in list(held.items()):
|
|
297
|
+
known = name in ('cancellation', 'stamps', 'acknowledgements')
|
|
298
|
+
made[name] = _copy_of(value) if known else rest(value)
|
|
299
|
+
return made
|
|
300
|
+
except Exception:
|
|
301
|
+
raise Refusal('bad-field', 'options.cancellations: a copy of a cancellation could not be read as plain data.') from None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --- times handed in ---
|
|
305
|
+
|
|
306
|
+
_MAX_TIME_MS = 8.64e15
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _time_clip(value):
|
|
310
|
+
if not _js.finite(value) or abs(value) > _MAX_TIME_MS:
|
|
311
|
+
return float('nan')
|
|
312
|
+
# Towards zero, and never -0.
|
|
313
|
+
return int(value) + 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _time_value(value):
|
|
317
|
+
"""A time handed in, as new Date(value).getTime() reads a number or a Date:
|
|
318
|
+
milliseconds since 1970. A Python datetime with a time zone stands for a
|
|
319
|
+
Date. Text, True, a datetime with no time zone or anything else would be
|
|
320
|
+
read by rules that differ from one device to another (text with no time
|
|
321
|
+
zone is taken as local time), so they are refused, as in the JavaScript
|
|
322
|
+
library."""
|
|
323
|
+
if _js.is_number(value):
|
|
324
|
+
return _time_clip(value)
|
|
325
|
+
if isinstance(value, datetime.datetime) and value.tzinfo is not None:
|
|
326
|
+
epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
|
|
327
|
+
return _time_clip((value - epoch) // datetime.timedelta(milliseconds=1))
|
|
328
|
+
raise Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.')
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --- the writer ---
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def open_recorder(book, slip, private_keys, issuer_keys, pass_=None, options=None, now=None, countersign_within=COUNTERSIGN_WITHIN_MS):
|
|
335
|
+
"""Open the stub writer for one agent under one slip.
|
|
336
|
+
|
|
337
|
+
book: the book so far, as text: at least the slip.
|
|
338
|
+
slip: the fingerprint of the slip.
|
|
339
|
+
private_keys: the agent's private keys, Ed25519 then ML-DSA-87.
|
|
340
|
+
issuer_keys: the thumbprints of the passkeys this agent's software
|
|
341
|
+
trusts; a slip from any other key allows nothing.
|
|
342
|
+
pass_: for a helper agent, the fingerprint of the pass it acts under.
|
|
343
|
+
options: further options for the checker, named as in the JavaScript
|
|
344
|
+
library (sealKeys, stampServices, blocks, vouchers, cancellations,
|
|
345
|
+
withoutMethods). They are read once, as a whole check reads them, and
|
|
346
|
+
fixed.
|
|
347
|
+
now: the clock, a function that gives milliseconds since 1970; the
|
|
348
|
+
device's by default. "act" takes an action at the clock's time, and
|
|
349
|
+
nothing is written that is dated more than 300 seconds ahead of it.
|
|
350
|
+
countersign_within: how long the other side is given to countersign,
|
|
351
|
+
in milliseconds; 30,000 by default.
|
|
352
|
+
|
|
353
|
+
Returns the writer (a Recorder). Raises a Refusal, "record-not-sound",
|
|
354
|
+
if the book so far has a problem."""
|
|
355
|
+
return Recorder(book, slip, private_keys, issuer_keys, pass_, options, now, countersign_within)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class Recorder:
|
|
359
|
+
"""The stub writer for one agent under one slip: before, record, act,
|
|
360
|
+
book, check, cancellations, add. Made by open_recorder."""
|
|
361
|
+
|
|
362
|
+
def __init__(self, book, slip, private_keys, issuer_keys, pass_=None, options=None, now=None, countersign_within=COUNTERSIGN_WITHIN_MS):
|
|
363
|
+
if now is None:
|
|
364
|
+
now = now_ms
|
|
365
|
+
if not callable(now):
|
|
366
|
+
raise Refusal('bad-field', 'now: must be a function that gives the time in milliseconds.')
|
|
367
|
+
# A clock that gives anything but a time is a clock that failed: no comparison with it would hold.
|
|
368
|
+
device = now
|
|
369
|
+
|
|
370
|
+
def clock():
|
|
371
|
+
time = device()
|
|
372
|
+
if not _js.finite(time):
|
|
373
|
+
raise Refusal('bad-field', 'now: the clock did not give a time in milliseconds.')
|
|
374
|
+
return time
|
|
375
|
+
|
|
376
|
+
self._now = clock
|
|
377
|
+
if not (_js.finite(countersign_within) and 1 <= countersign_within <= _MAX_WAIT_MS):
|
|
378
|
+
raise Refusal('bad-field', 'countersignWithin: must be a number of milliseconds from 1 to 2,147,483,647.')
|
|
379
|
+
self._within = countersign_within
|
|
380
|
+
self._slip = slip
|
|
381
|
+
self._pass = pass_
|
|
382
|
+
self._private_keys = private_keys
|
|
383
|
+
# Whom the writer trusts is fixed when it is opened: read once, as a
|
|
384
|
+
# whole check reads it, and copied. The passkeys it trusts are its
|
|
385
|
+
# own "issuer_keys", whatever the options say, read through the
|
|
386
|
+
# same fixed copy.
|
|
387
|
+
# (The options may also be named in Python's way, "stamp_services", as for check_book.)
|
|
388
|
+
named = {name: value for name, value in options.items() if name in _SNAKE and name != 'issuer_keys'} if isinstance(options, Mapping) else {}
|
|
389
|
+
fixed = fixed_options(options, **named)
|
|
390
|
+
own = fixed_options({'issuerKeys': issuer_keys} if issuer_keys is not None else {})
|
|
391
|
+
try:
|
|
392
|
+
base = {}
|
|
393
|
+
for name, value in fixed.items():
|
|
394
|
+
if name != 'issuerKeys':
|
|
395
|
+
base[name] = value
|
|
396
|
+
base.update(own)
|
|
397
|
+
# A list longer than a check accepts is refused by the check; it is not walked through here.
|
|
398
|
+
cancellations = base.get('cancellations')
|
|
399
|
+
if isinstance(cancellations, list) and len(cancellations) <= _MAX_HANDED:
|
|
400
|
+
base['cancellations'] = [_plain_held(c) for c in cancellations]
|
|
401
|
+
except Refusal:
|
|
402
|
+
raise
|
|
403
|
+
except Exception:
|
|
404
|
+
raise Refusal('bad-field', 'options: a member could not be read.') from None
|
|
405
|
+
self._base = base
|
|
406
|
+
# The person's cancellations that the writer holds beside its book:
|
|
407
|
+
# those given when it was opened, and those of its own slip that
|
|
408
|
+
# were handed to it since and could not yet be written into the
|
|
409
|
+
# book ("kept"). A cancellation of another slip that cannot be added
|
|
410
|
+
# is nothing to this writer and is not kept, so no number of them
|
|
411
|
+
# can crowd out one that matters. Each is written into the book as
|
|
412
|
+
# soon as it can be (_write_waiting). Until then it lives only in
|
|
413
|
+
# this writer: "cancellations" hands it back, so that the caller can
|
|
414
|
+
# keep it with the book. A check may be handed 16 at most: see
|
|
415
|
+
# _beside.
|
|
416
|
+
self._given = base['cancellations'] if isinstance(base.get('cancellations'), list) else []
|
|
417
|
+
self._kept = []
|
|
418
|
+
# Those given at opening that the book does not hold: written into it as the kept ones are.
|
|
419
|
+
self._unwritten = []
|
|
420
|
+
# The copies given at opening that already hold an acknowledgement
|
|
421
|
+
# of this writer's own agent: the writer puts no second one with them.
|
|
422
|
+
self._own_acked = []
|
|
423
|
+
# Whether the person's cancellation of this writer's slip was handed
|
|
424
|
+
# to it. From then on it takes no action, whatever becomes of the
|
|
425
|
+
# cancellation itself.
|
|
426
|
+
self._stopped = False
|
|
427
|
+
# The acknowledgements that this writer's own agent has signed, by
|
|
428
|
+
# the fingerprint of the cancellation each is for: those the book
|
|
429
|
+
# holds ("written"), and those signed since the writer was opened.
|
|
430
|
+
# One cancellation is acknowledged once.
|
|
431
|
+
self._acknowledged = {}
|
|
432
|
+
self._written = set()
|
|
433
|
+
# Those that could not yet follow their cancellation into the book,
|
|
434
|
+
# by the fingerprint of the cancellation, with the cancellation itself.
|
|
435
|
+
self._waiting = {}
|
|
436
|
+
without = base.get('withoutMethods')
|
|
437
|
+
self._without = without if isinstance(without, list) else []
|
|
438
|
+
self._text = book + '\n' if isinstance(book, str) and book != '' and not book.endswith('\n') else book
|
|
439
|
+
|
|
440
|
+
# One call at a time, from any thread. The answer to "would this be
|
|
441
|
+
# outside the slip?" holds only until the next stub is written.
|
|
442
|
+
self._lock = threading.Lock()
|
|
443
|
+
self._owner = None
|
|
444
|
+
|
|
445
|
+
# The book is read once, here. "_carried" always stands for exactly
|
|
446
|
+
# the text of the book: every line that joins the text is read into it.
|
|
447
|
+
self._carried = start_carried(base)
|
|
448
|
+
carry_on(self._carried, self._text)
|
|
449
|
+
result = self._standing()['result']
|
|
450
|
+
if result['summary']['problemFound']:
|
|
451
|
+
raise _not_written('A problem was found in the record so far. It must be looked at before the agent acts again.')
|
|
452
|
+
self._after = self._last_stub(result['entries'])
|
|
453
|
+
# What was given at opening: the first sound acknowledgement of this
|
|
454
|
+
# writer's own agent that came with a cancellation is the one it
|
|
455
|
+
# goes on handing back; a cancellation that the book does not hold
|
|
456
|
+
# waits to be written.
|
|
457
|
+
given = self._given
|
|
458
|
+
for i, h in enumerate(result['held']):
|
|
459
|
+
if not h['used']:
|
|
460
|
+
continue
|
|
461
|
+
# The person's cancellation of this writer's own slip, given when it was opened, stops it from the start.
|
|
462
|
+
if h['slip'] == slip:
|
|
463
|
+
self._stopped = True
|
|
464
|
+
for j, a in enumerate(h['acknowledgements']):
|
|
465
|
+
if a['state'] != 'valid' or h['slip'] != slip or a['pass'] != pass_:
|
|
466
|
+
continue
|
|
467
|
+
if not _same(self._own_acked, given[i]):
|
|
468
|
+
self._own_acked.append(given[i])
|
|
469
|
+
if h['fingerprint'] not in self._acknowledged:
|
|
470
|
+
self._acknowledged[h['fingerprint']] = given[i]['acknowledgements'][j]
|
|
471
|
+
if h['inBook'] is None:
|
|
472
|
+
self._unwritten.append({'entry': given[i], 'slip': h['slip'], 'when': nan_time(h['content']['when']), 'fingerprint': h['fingerprint'], 'tried': False})
|
|
473
|
+
# The first acknowledgement of this writer's own agent that the book
|
|
474
|
+
# holds for a cancellation is the one it goes on handing back.
|
|
475
|
+
lines = self._text.split('\n') if isinstance(self._text, str) else []
|
|
476
|
+
for e in result['entries']:
|
|
477
|
+
if e['kind'] != 'acknowledgement' or e['problems'] or e.get('slip') != slip or e.get('pass') != pass_:
|
|
478
|
+
continue
|
|
479
|
+
if e['content']['cancellation'] in self._written:
|
|
480
|
+
continue
|
|
481
|
+
self._acknowledged[e['content']['cancellation']] = _js.parse(lines[e['index']])['acknowledgement']
|
|
482
|
+
self._written.add(e['content']['cancellation'])
|
|
483
|
+
# An acknowledgement given at opening, for a cancellation that the
|
|
484
|
+
# book holds without it, follows the cancellation into the book: it
|
|
485
|
+
# was still waiting when the writer that signed it handed it back.
|
|
486
|
+
for i, h in enumerate(result['held']):
|
|
487
|
+
if h['used'] and h['slip'] == slip and h['inBook'] is not None and h['fingerprint'] in self._acknowledged and h['fingerprint'] not in self._written:
|
|
488
|
+
self._waiting[h['fingerprint']] = given[i]['cancellation']
|
|
489
|
+
# The services the slip names, to check a countersignature before it goes into the book.
|
|
490
|
+
slip_entry = next((e for e in result['entries'] if e['kind'] == 'slip' and e['fingerprint'] == slip), None)
|
|
491
|
+
self._services = slip_entry['content']['with'] if slip_entry and slip_entry.get('content') else []
|
|
492
|
+
|
|
493
|
+
# --- what the writer holds beside its book ---
|
|
494
|
+
#
|
|
495
|
+
# An acknowledgement that waits to follow its cancellation into the book
|
|
496
|
+
# is handed back too, with the cancellation, unless a copy given at
|
|
497
|
+
# opening already carries it. So a writer opened again from what this
|
|
498
|
+
# one hands back writes the same acknowledgement, not a second one.
|
|
499
|
+
# What the writer holds beside its book, the most needed first: the
|
|
500
|
+
# person's cancellations of its own slip that the book does not hold
|
|
501
|
+
# (those kept, then those given at opening), which stop the writer;
|
|
502
|
+
# then the acknowledgements that wait, in the order in which they began
|
|
503
|
+
# to wait (each carried by a copy given at opening, or handed back by
|
|
504
|
+
# itself); then every other copy given at opening. A check may be
|
|
505
|
+
# handed sixteen at most: where there are more, the last give way, so
|
|
506
|
+
# that copies of other slips, or of cancellations the book holds, can
|
|
507
|
+
# never crowd out one that matters.
|
|
508
|
+
|
|
509
|
+
def _own_unwritten(self):
|
|
510
|
+
return [u['entry'] for u in self._unwritten if u['slip'] == self._slip]
|
|
511
|
+
|
|
512
|
+
def _waiting_part(self, own=None):
|
|
513
|
+
if own is None:
|
|
514
|
+
own = self._own_unwritten()
|
|
515
|
+
part = []
|
|
516
|
+
for cancellation, record in self._waiting.items():
|
|
517
|
+
carriers = [g for g in self._given if _js.truthy(g) and _js.truthy(_member(g, 'cancellation')) and _member(g['cancellation'], 'payload') == _member(record, 'payload')]
|
|
518
|
+
if not carriers:
|
|
519
|
+
part.append({'cancellation': record, 'acknowledgements': [self._acknowledged.get(cancellation)]})
|
|
520
|
+
for g in carriers:
|
|
521
|
+
if not _same(own, g) and not _same(part, g):
|
|
522
|
+
part.append(g)
|
|
523
|
+
return part
|
|
524
|
+
|
|
525
|
+
def _room(self):
|
|
526
|
+
own = self._own_unwritten()
|
|
527
|
+
return _MAX_HANDED - len(self._kept) - len(own) - len(self._waiting_part(own))
|
|
528
|
+
|
|
529
|
+
def _beside(self):
|
|
530
|
+
if not self._kept and not self._waiting:
|
|
531
|
+
return self._given
|
|
532
|
+
own = self._own_unwritten()
|
|
533
|
+
waits = self._waiting_part(own)
|
|
534
|
+
rest = [g for g in self._given if not _same(own, g) and not _same(waits, g)]
|
|
535
|
+
return ([k['entry'] for k in self._kept] + own + waits + rest)[:_MAX_HANDED]
|
|
536
|
+
|
|
537
|
+
def _checking(self):
|
|
538
|
+
return {**self._base, 'cancellations': self._beside()} if self._kept or self._waiting else self._base
|
|
539
|
+
|
|
540
|
+
def _while_stopped(self, answer):
|
|
541
|
+
if self._stopped and answer['allowed']:
|
|
542
|
+
answer['allowed'] = False
|
|
543
|
+
answer['breaches'].append({'code': 'after-cancellation', 'message': 'The person cancelled the slip: the cancellation was handed to this stub writer.'})
|
|
544
|
+
return answer
|
|
545
|
+
|
|
546
|
+
# Where the agent's own chain stands: the last stub under this slip, and
|
|
547
|
+
# under this pass if it acts under one.
|
|
548
|
+
def _last_stub(self, entries):
|
|
549
|
+
last = None
|
|
550
|
+
for e in entries:
|
|
551
|
+
if e['kind'] == 'stub' and e.get('slip') == self._slip and e.get('pass') == self._pass:
|
|
552
|
+
last = {'seq': e['content']['seq'], 'fingerprint': e['fingerprint']}
|
|
553
|
+
return last
|
|
554
|
+
|
|
555
|
+
# The answer for the book as it stands, or for a reader split off it, with the cancellations held beside the book.
|
|
556
|
+
def _standing(self, of=None):
|
|
557
|
+
return answer_of(self._carried if of is None else of, self._checking())
|
|
558
|
+
|
|
559
|
+
# --- one call at a time ---
|
|
560
|
+
|
|
561
|
+
def _in_turn(self, work):
|
|
562
|
+
mark = _INSIDE.get()
|
|
563
|
+
if (mark is not None and mark.writer is self and mark.running) or self._owner == threading.get_ident():
|
|
564
|
+
raise RuntimeError(_INSIDE_MESSAGE)
|
|
565
|
+
with self._lock:
|
|
566
|
+
self._owner = threading.get_ident()
|
|
567
|
+
try:
|
|
568
|
+
return work()
|
|
569
|
+
finally:
|
|
570
|
+
self._owner = None
|
|
571
|
+
|
|
572
|
+
# What the caller hands in to be run (the action) is run under a mark,
|
|
573
|
+
# so that a call to the writer from inside it is caught. The mark holds
|
|
574
|
+
# only while the writer is waiting for that work.
|
|
575
|
+
def _run_inside(self, perform):
|
|
576
|
+
mark = _Mark(self)
|
|
577
|
+
token = _INSIDE.set(mark)
|
|
578
|
+
try:
|
|
579
|
+
value = perform()
|
|
580
|
+
finally:
|
|
581
|
+
mark.running = False
|
|
582
|
+
_INSIDE.reset(token)
|
|
583
|
+
if inspect.isawaitable(value):
|
|
584
|
+
_refuse_awaitable(value, 'perform: the action must be an ordinary function that takes the action and gives its result. It gave a coroutine, which was not run.')
|
|
585
|
+
return value
|
|
586
|
+
|
|
587
|
+
# The other side is asked in a thread of its own, under the same mark,
|
|
588
|
+
# and given "countersign_within" milliseconds to answer. After that the
|
|
589
|
+
# writer goes on, the mark no longer holds, and whatever the function
|
|
590
|
+
# gives later is not used.
|
|
591
|
+
def _ask_other_side(self, countersign, stub):
|
|
592
|
+
mark = _Mark(self)
|
|
593
|
+
context = contextvars.copy_context()
|
|
594
|
+
context.run(_INSIDE.set, mark)
|
|
595
|
+
answer = concurrent.futures.Future()
|
|
596
|
+
|
|
597
|
+
def job():
|
|
598
|
+
try:
|
|
599
|
+
value = countersign(stub)
|
|
600
|
+
if inspect.isawaitable(value):
|
|
601
|
+
_refuse_awaitable(value, 'countersign: must be an ordinary function that gives the countersignature, or None. It gave a coroutine, which was not run.')
|
|
602
|
+
answer.set_result(value)
|
|
603
|
+
except Exception as e: # handed on to the writer, which decides
|
|
604
|
+
answer.set_exception(e)
|
|
605
|
+
except BaseException as e: # noqa: BLE001
|
|
606
|
+
# SystemExit, KeyboardInterrupt and the like, raised by the other side's
|
|
607
|
+
# function in its own thread, are a failure like any other: the stub
|
|
608
|
+
# stands one-sided.
|
|
609
|
+
answer.set_exception(RuntimeError(f'the countersign function raised {type(e).__name__}'))
|
|
610
|
+
|
|
611
|
+
threading.Thread(target=context.run, args=(job,), daemon=True, name='provared-countersign').start()
|
|
612
|
+
try:
|
|
613
|
+
return answer.result(timeout=self._within / 1000)
|
|
614
|
+
except concurrent.futures.TimeoutError:
|
|
615
|
+
raise RuntimeError('no answer in time') from None
|
|
616
|
+
finally:
|
|
617
|
+
mark.running = False
|
|
618
|
+
|
|
619
|
+
# What a call hands in, copied at the moment of the call.
|
|
620
|
+
def _asked(self, request, more):
|
|
621
|
+
if request is None or _scalar(request) or isinstance(request, bytes):
|
|
622
|
+
raise Refusal('bad-field', 'The request must be an object.')
|
|
623
|
+
if not isinstance(more, dict):
|
|
624
|
+
more = {}
|
|
625
|
+
call = {
|
|
626
|
+
'request': _plain(request, 'The request'),
|
|
627
|
+
'when': _ABSENT if more.get('when') is None else _time_value(more['when']),
|
|
628
|
+
'terms': more.get('terms', _ABSENT),
|
|
629
|
+
'countersign': more.get('countersign', _ABSENT),
|
|
630
|
+
'hasApproval': more.get('approval') is not None,
|
|
631
|
+
'approval': _ABSENT,
|
|
632
|
+
}
|
|
633
|
+
if call['hasApproval']:
|
|
634
|
+
try:
|
|
635
|
+
call['approval'] = _clone(_member(more['approval'], 'record'))
|
|
636
|
+
except Exception:
|
|
637
|
+
call['approval'] = _ABSENT # not a record: refused when the stub is written
|
|
638
|
+
return call
|
|
639
|
+
|
|
640
|
+
def _calling(self, request, more, work):
|
|
641
|
+
call = self._asked(request, more)
|
|
642
|
+
return self._in_turn(lambda: work(call))
|
|
643
|
+
|
|
644
|
+
def _proposal_from(self, call, when):
|
|
645
|
+
request = call['request']
|
|
646
|
+
if not isinstance(request, dict):
|
|
647
|
+
request = {}
|
|
648
|
+
proposal = {'slip': self._slip, 'action': request.get('action')}
|
|
649
|
+
for name in ('amount', 'with', 'details'):
|
|
650
|
+
# An empty list of documents is the same as none, as the stub itself is written.
|
|
651
|
+
if name == 'details' and isinstance(request.get('details'), list) and len(request['details']) == 0:
|
|
652
|
+
continue
|
|
653
|
+
if name in request:
|
|
654
|
+
proposal[name] = request[name]
|
|
655
|
+
if _js.truthy(self._pass):
|
|
656
|
+
proposal['pass'] = self._pass
|
|
657
|
+
proposal['when'] = when
|
|
658
|
+
if call['terms'] is not _ABSENT:
|
|
659
|
+
proposal['terms'] = call['terms']
|
|
660
|
+
if call['hasApproval'] and call['approval'] is not _ABSENT:
|
|
661
|
+
proposal['approval'] = call['approval']
|
|
662
|
+
return proposal
|
|
663
|
+
|
|
664
|
+
def _ahead_of_clock(self, when):
|
|
665
|
+
return when > self._now() + CLOCK_ALLOWANCE_MS
|
|
666
|
+
|
|
667
|
+
def _behind_clock(self, when):
|
|
668
|
+
return when < self._now() - CLOCK_ALLOWANCE_MS
|
|
669
|
+
|
|
670
|
+
# The stub for an action, checked with the whole book before anything is
|
|
671
|
+
# done with it. Nothing on its line may be dated ahead of the clock: the
|
|
672
|
+
# stub's own date is looked at before this, and the approval's here. It
|
|
673
|
+
# is read by a reader split off the book's own, which is handed back: if
|
|
674
|
+
# the stub is not written after all, that reader is simply not used.
|
|
675
|
+
def _fits_book(self, made, otherwise):
|
|
676
|
+
tried = fork_carried(self._carried)
|
|
677
|
+
carry_on(tried, made['line'] + '\n')
|
|
678
|
+
checked = self._standing(tried)['result']
|
|
679
|
+
if checked['summary']['problemFound']:
|
|
680
|
+
raise _not_written(otherwise)
|
|
681
|
+
if latest_when(checked['entries'][-1]) > self._now() + CLOCK_ALLOWANCE_MS:
|
|
682
|
+
raise _not_written('The approval that goes with this action is dated ahead of the clock, so nothing was written.')
|
|
683
|
+
return tried
|
|
684
|
+
|
|
685
|
+
# The fingerprint of an approval is worked out here, from the record
|
|
686
|
+
# itself. One handed over with it is not relied on.
|
|
687
|
+
@staticmethod
|
|
688
|
+
def _approval_fingerprint(record):
|
|
689
|
+
try:
|
|
690
|
+
return fingerprint(from_base64url(record['payload']))
|
|
691
|
+
except Exception:
|
|
692
|
+
raise Refusal('approval-invalid', 'The approval handed over is not a signed record.') from None
|
|
693
|
+
|
|
694
|
+
# The stub for an action, with the approval that goes with it, as a line.
|
|
695
|
+
def _compose_stub(self, call, when):
|
|
696
|
+
request = call['request'] if isinstance(call['request'], dict) else {}
|
|
697
|
+
approval = self._approval_fingerprint(call['approval']) if call['hasApproval'] else None
|
|
698
|
+
stub = write_stub(
|
|
699
|
+
{
|
|
700
|
+
'slip': self._slip,
|
|
701
|
+
'after': self._after,
|
|
702
|
+
'action': request.get('action'),
|
|
703
|
+
'amount': request.get('amount'),
|
|
704
|
+
'with': request.get('with'),
|
|
705
|
+
'details': request.get('details'),
|
|
706
|
+
'approval': approval,
|
|
707
|
+
'terms': None if call['terms'] is _ABSENT else call['terms'],
|
|
708
|
+
'pass': self._pass,
|
|
709
|
+
'when': when,
|
|
710
|
+
},
|
|
711
|
+
self._private_keys,
|
|
712
|
+
)
|
|
713
|
+
entry = {'stub': stub['record']}
|
|
714
|
+
if call['hasApproval']:
|
|
715
|
+
entry['approval'] = call['approval']
|
|
716
|
+
line = entry_line(entry)
|
|
717
|
+
if len(utf8(line)) > MAX_LINE_BYTES:
|
|
718
|
+
raise Refusal('too-large', 'The record of this action is longer than a line of a book may be.')
|
|
719
|
+
return {'stub': stub, 'entry': entry, 'line': line, 'countersignature': {'accepted': False, 'problem': None}}
|
|
720
|
+
|
|
721
|
+
# Ask the other side for its countersignature, and put it with the stub
|
|
722
|
+
# if it checks: the right keys, the right stub, not dated ahead of the
|
|
723
|
+
# clock, and a line that still fits. One that does not check is left
|
|
724
|
+
# out, and the stub stands one-sided, which is what happened.
|
|
725
|
+
def _countersigned(self, made, call):
|
|
726
|
+
if not callable(call['countersign']):
|
|
727
|
+
return made
|
|
728
|
+
countersignature = {'accepted': False, 'problem': None}
|
|
729
|
+
line = made['line']
|
|
730
|
+
try:
|
|
731
|
+
# The other side is handed a copy: nothing it does to it changes the stub.
|
|
732
|
+
given = self._ask_other_side(call['countersign'], copy.deepcopy(made['stub']['record']))
|
|
733
|
+
if _js.truthy(given):
|
|
734
|
+
# What it hands back is copied at once. The copy is what is checked, and what joins the stub.
|
|
735
|
+
try:
|
|
736
|
+
kept = _clone(given)
|
|
737
|
+
except Exception:
|
|
738
|
+
raise Refusal('countersignature-invalid', 'What the other side handed back is not a signed record.') from None
|
|
739
|
+
w = _member(call['request'], 'with')
|
|
740
|
+
service = next((s for s in self._services if s.get('id') == w), None) if isinstance(w, str) else None
|
|
741
|
+
checked = check_countersignature(kept, made['stub']['fingerprint'], service, self._without)
|
|
742
|
+
if checked['state'] != 'valid':
|
|
743
|
+
raise Refusal('countersignature-invalid', 'This device could not check the countersignature.')
|
|
744
|
+
if nan_time(checked['when']) > self._now() + CLOCK_ALLOWANCE_MS:
|
|
745
|
+
raise Refusal('countersignature-invalid', 'The countersignature is dated ahead of the clock.')
|
|
746
|
+
with_it = entry_line({**made['entry'], 'countersignature': kept})
|
|
747
|
+
if len(utf8(with_it)) > MAX_LINE_BYTES:
|
|
748
|
+
raise Refusal('too-large', 'With the countersignature, the line would be longer than a line of a book may be.')
|
|
749
|
+
line = with_it
|
|
750
|
+
countersignature['accepted'] = True
|
|
751
|
+
except Exception as e:
|
|
752
|
+
countersignature['problem'] = (
|
|
753
|
+
problem_from(e) if isinstance(e, Refusal) else {'code': 'countersignature-missing', 'message': 'Asking the other side for its countersignature failed, or took too long.'}
|
|
754
|
+
)
|
|
755
|
+
return {**made, 'line': line, 'countersignature': countersignature}
|
|
756
|
+
|
|
757
|
+
# The line joins the book. The reader that takes the book's place is
|
|
758
|
+
# the one the stub was tried on, if the line is still the one tried.
|
|
759
|
+
# With a countersignature the line is another: it is read from where
|
|
760
|
+
# the book stood, and the reader that tried the stub alone is not used.
|
|
761
|
+
def _commit(self, composed, made, tried):
|
|
762
|
+
following = tried
|
|
763
|
+
if composed['line'] != made['line']:
|
|
764
|
+
following = fork_carried(self._carried)
|
|
765
|
+
carry_on(following, composed['line'] + '\n')
|
|
766
|
+
self._carried = promote_carried(following)
|
|
767
|
+
self._text += composed['line'] + '\n'
|
|
768
|
+
stub = composed['stub']
|
|
769
|
+
self._after = {'seq': stub['seq'], 'fingerprint': stub['fingerprint']}
|
|
770
|
+
return {**stub, 'line': composed['line'], 'countersignature': composed['countersignature']}
|
|
771
|
+
|
|
772
|
+
# A line that someone else made is tried on a reader split off the
|
|
773
|
+
# book's own, which may read a line of any kind. If the book passes its
|
|
774
|
+
# check with the line, and the line is not dated ahead of the clock,
|
|
775
|
+
# that reader takes the book's place and the line joins the text.
|
|
776
|
+
# Otherwise it is thrown away, and the book's own reader is as it was.
|
|
777
|
+
# "at" is the clock, read once by the caller.
|
|
778
|
+
def _take_line(self, line, at, options=None):
|
|
779
|
+
if options is None:
|
|
780
|
+
options = self._checking()
|
|
781
|
+
tried = fork_carried(self._carried, True)
|
|
782
|
+
carry_on(tried, line + '\n')
|
|
783
|
+
checked = answer_of(tried, options)['result']
|
|
784
|
+
if checked['summary']['problemFound']:
|
|
785
|
+
return {'fits': False, 'ahead': False, 'why': 'with it, the book would not pass its check.'}
|
|
786
|
+
if latest_when(checked['entries'][-1]) > at + CLOCK_ALLOWANCE_MS:
|
|
787
|
+
return {'fits': False, 'ahead': True, 'why': 'it is dated ahead of the clock.'}
|
|
788
|
+
self._carried = promote_carried(tried)
|
|
789
|
+
self._text += line + '\n'
|
|
790
|
+
return {'fits': True, 'checked': checked}
|
|
791
|
+
|
|
792
|
+
# A cancellation held beside the book is written into the book as soon
|
|
793
|
+
# as it can be: once the date it gives is no longer ahead of the clock.
|
|
794
|
+
# It goes in as the entry it is, without the acknowledgements that came
|
|
795
|
+
# with it. If the book would not pass its check with it even then, it
|
|
796
|
+
# is not tried again, and the writer goes on holding it.
|
|
797
|
+
def _write_waiting(self, at):
|
|
798
|
+
for items in (self._kept, self._unwritten):
|
|
799
|
+
for item in list(items):
|
|
800
|
+
if item['tried'] or item['when'] > at + CLOCK_ALLOWANCE_MS:
|
|
801
|
+
continue
|
|
802
|
+
entry = {'cancellation': item['entry']['cancellation']}
|
|
803
|
+
if 'stamps' in item['entry']:
|
|
804
|
+
entry['stamps'] = item['entry']['stamps']
|
|
805
|
+
taken = self._take_line(entry_line(entry), at)
|
|
806
|
+
if not taken['fits']:
|
|
807
|
+
item['tried'] = True
|
|
808
|
+
continue
|
|
809
|
+
# In the book, it is no longer one that waits.
|
|
810
|
+
for k, x in enumerate(items):
|
|
811
|
+
if x is item:
|
|
812
|
+
del items[k]
|
|
813
|
+
break
|
|
814
|
+
# The acknowledgement that was signed when it was handed over follows it into the book.
|
|
815
|
+
if item['fingerprint'] in self._acknowledged:
|
|
816
|
+
self._acknowledge(item['fingerprint'], item['entry']['cancellation'], True, at)
|
|
817
|
+
# An acknowledgement that could not follow its cancellation into the
|
|
818
|
+
# book, because it was dated ahead of the clock, is tried again.
|
|
819
|
+
for cancellation in list(self._waiting):
|
|
820
|
+
taken = self._take_line(entry_line({'acknowledgement': self._acknowledged.get(cancellation)}), at)
|
|
821
|
+
if taken['fits']:
|
|
822
|
+
self._written.add(cancellation)
|
|
823
|
+
if taken['fits'] or not taken['ahead']:
|
|
824
|
+
self._waiting.pop(cancellation, None)
|
|
825
|
+
|
|
826
|
+
# The acknowledgement goes with the cancellation that the writer holds
|
|
827
|
+
# beside its book, so that "cancellations" hands the two back together.
|
|
828
|
+
# A copy holds four at most. The writer's own is always kept: where the
|
|
829
|
+
# copy already holds four, the last of the others gives way to it.
|
|
830
|
+
# Another agent's is put with the copy only where there is room.
|
|
831
|
+
def _put_with(self, entry, record, own=True):
|
|
832
|
+
if not _js.truthy(record):
|
|
833
|
+
return
|
|
834
|
+
items = entry['acknowledgements'] if isinstance(entry.get('acknowledgements'), list) else []
|
|
835
|
+
if any(_js.truthy(a) and _member(a, 'payload') == _member(record, 'payload') for a in items):
|
|
836
|
+
return
|
|
837
|
+
# A copy given at opening that already holds one of the agent's own takes no second one.
|
|
838
|
+
if own and _same(self._own_acked, entry):
|
|
839
|
+
return
|
|
840
|
+
if len(items) >= 4:
|
|
841
|
+
if not own:
|
|
842
|
+
return
|
|
843
|
+
items = items[:3]
|
|
844
|
+
entry['acknowledgements'] = [*items, copy.deepcopy(record)]
|
|
845
|
+
|
|
846
|
+
# The agent's side says that it was handed the person's cancellation of
|
|
847
|
+
# its slip, and when: signed with this writer's own keys, once for each
|
|
848
|
+
# cancellation, and dated by the writer's clock. Where the book holds
|
|
849
|
+
# the cancellation, the acknowledgement follows it into the book. It is
|
|
850
|
+
# handed back to the caller, to be given to the person. If it cannot be
|
|
851
|
+
# signed, or does not check (the writer was opened with keys that are
|
|
852
|
+
# not the agent's), there is none; the cancellation stops the writer all
|
|
853
|
+
# the same.
|
|
854
|
+
#
|
|
855
|
+
# It is not dated before the writer's own last stub, nor before a pass
|
|
856
|
+
# that its agent handed on. Either may be dated up to 300 seconds ahead
|
|
857
|
+
# of the clock, and the agent was not told before it made them: an
|
|
858
|
+
# acknowledgement dated by the clock alone would have them read as made
|
|
859
|
+
# after the agent was told.
|
|
860
|
+
def _acknowledge(self, cancellation, cancellation_record, in_book, at):
|
|
861
|
+
record = self._acknowledged.get(cancellation)
|
|
862
|
+
if not _js.truthy(record):
|
|
863
|
+
when = at
|
|
864
|
+
for e in self._standing()['result']['entries']:
|
|
865
|
+
if e.get('slip') != self._slip or not _js.truthy(e.get('content')):
|
|
866
|
+
continue
|
|
867
|
+
content = e['content']
|
|
868
|
+
own = (e['kind'] == 'stub' and e.get('pass') == self._pass) or (e['kind'] == 'pass' and (content['from'] if 'from' in content else None) == self._pass)
|
|
869
|
+
if own and nan_time(content['when']) > when:
|
|
870
|
+
when = nan_time(content['when'])
|
|
871
|
+
try:
|
|
872
|
+
made = write_acknowledgement({'slip': self._slip, 'cancellation': cancellation, 'pass': self._pass, 'when': when}, self._private_keys)['record']
|
|
873
|
+
except Exception:
|
|
874
|
+
return None
|
|
875
|
+
# Checked as a reader will check it, beside the person's own copy of the cancellation.
|
|
876
|
+
held = answer_of(self._carried, {**self._base, 'cancellations': [{'cancellation': cancellation_record, 'acknowledgements': [made]}]})['result']['held']
|
|
877
|
+
beside = held[0] if held else None
|
|
878
|
+
if not beside or len(beside['acknowledgements']) != 1 or beside['acknowledgements'][0]['state'] != 'valid':
|
|
879
|
+
return None
|
|
880
|
+
record = made
|
|
881
|
+
self._acknowledged[cancellation] = record
|
|
882
|
+
if in_book and cancellation not in self._written:
|
|
883
|
+
taken = self._take_line(entry_line({'acknowledgement': record}), at)
|
|
884
|
+
if taken['fits']:
|
|
885
|
+
self._written.add(cancellation)
|
|
886
|
+
elif taken['ahead']:
|
|
887
|
+
self._waiting[cancellation] = cancellation_record
|
|
888
|
+
# It goes with the cancellation wherever the writer holds that beside its book.
|
|
889
|
+
held = next((k for k in self._kept if k['fingerprint'] == cancellation), None)
|
|
890
|
+
if held:
|
|
891
|
+
self._put_with(held['entry'], record)
|
|
892
|
+
opened = next(
|
|
893
|
+
(h for h in self._given if _js.truthy(h) and _js.truthy(_member(h, 'cancellation')) and _member(h['cancellation'], 'payload') == _member(cancellation_record, 'payload')),
|
|
894
|
+
None,
|
|
895
|
+
)
|
|
896
|
+
if opened:
|
|
897
|
+
self._put_with(opened, record)
|
|
898
|
+
return copy.deepcopy(record)
|
|
899
|
+
|
|
900
|
+
@staticmethod
|
|
901
|
+
def _refused(message, acknowledgement=None):
|
|
902
|
+
e = _not_written(message)
|
|
903
|
+
if _js.truthy(acknowledgement):
|
|
904
|
+
e.acknowledgement = acknowledgement
|
|
905
|
+
return e
|
|
906
|
+
|
|
907
|
+
# The acknowledgements handed over with the person's cancellation of
|
|
908
|
+
# this writer's slip. Each is checked by itself, so that one which does
|
|
909
|
+
# not check is never taken for one which does. The first sound one of
|
|
910
|
+
# this writer's own agent is the one the writer goes on handing back,
|
|
911
|
+
# unless it already has one; any further one of its own agent is let
|
|
912
|
+
# go, so that a copy never holds two. The sound ones of other agents are
|
|
913
|
+
# handed back, to stay with the copy. At most sixteen are looked at.
|
|
914
|
+
def _sort_handed(self, cancellation, cancellation_record, handed):
|
|
915
|
+
others = []
|
|
916
|
+
if not isinstance(handed, list):
|
|
917
|
+
return others
|
|
918
|
+
for record in handed[:_MAX_HANDED]:
|
|
919
|
+
held = answer_of(self._carried, {**self._base, 'cancellations': [{'cancellation': cancellation_record, 'acknowledgements': [record]}]})['result']['held']
|
|
920
|
+
beside = held[0] if held else None
|
|
921
|
+
if not beside or not beside['used'] or len(beside['acknowledgements']) != 1 or beside['acknowledgements'][0]['state'] != 'valid':
|
|
922
|
+
continue
|
|
923
|
+
if beside['acknowledgements'][0]['pass'] != self._pass:
|
|
924
|
+
others.append(record)
|
|
925
|
+
elif cancellation not in self._acknowledged:
|
|
926
|
+
self._acknowledged[cancellation] = record
|
|
927
|
+
return others
|
|
928
|
+
|
|
929
|
+
# The writer's own acknowledgement, and the sound ones of other agents, go with a copy the writer holds.
|
|
930
|
+
def _put_handed(self, entry, cancellation, others):
|
|
931
|
+
self._put_with(entry, self._acknowledged.get(cancellation))
|
|
932
|
+
for other in others:
|
|
933
|
+
self._put_with(entry, other, False)
|
|
934
|
+
|
|
935
|
+
# The time-stamps that a cancellation should go with: those a copy of it
|
|
936
|
+
# already holds, and those handed over now, none twice. Each is judged
|
|
937
|
+
# by itself, beside the book: one with which the cancellation does not
|
|
938
|
+
# pass its check is left out. Those that this writer counts come first,
|
|
939
|
+
# then the others, four at most, so that a time-stamp the writer counts
|
|
940
|
+
# is never crowded out by one it does not. Only a set with which the
|
|
941
|
+
# book still passes its check is taken. None where nothing changes.
|
|
942
|
+
def _merged_stamps(self, cancellation_record, holds, offered):
|
|
943
|
+
def key_of(s):
|
|
944
|
+
try:
|
|
945
|
+
return canonical_json(s)
|
|
946
|
+
except Exception:
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
held = {key_of(s) for s in holds}
|
|
950
|
+
every = list(holds)
|
|
951
|
+
for s in offered:
|
|
952
|
+
key = key_of(s)
|
|
953
|
+
if key is None or key in held:
|
|
954
|
+
continue
|
|
955
|
+
held.add(key)
|
|
956
|
+
every.append(s)
|
|
957
|
+
if len(every) == len(holds):
|
|
958
|
+
return None
|
|
959
|
+
counted = []
|
|
960
|
+
uncounted = []
|
|
961
|
+
for s in every:
|
|
962
|
+
found = answer_of(self._carried, {**self._base, 'cancellations': [{'cancellation': cancellation_record, 'stamps': [s]}]})['result']['held']
|
|
963
|
+
one = found[0] if found else None
|
|
964
|
+
if not one or not one['used'] or one['problems']:
|
|
965
|
+
continue
|
|
966
|
+
if _js.truthy(one.get('stampedAt')):
|
|
967
|
+
counted.append((s, nan_time(one['stampedAt'])))
|
|
968
|
+
else:
|
|
969
|
+
uncounted.append(s)
|
|
970
|
+
# Of those it counts, the earliest come first: they place the cancellation earliest in time.
|
|
971
|
+
counted.sort(key=lambda c: c[1])
|
|
972
|
+
stamps = ([c[0] for c in counted] + uncounted)[:MAX_STAMPS]
|
|
973
|
+
if len(stamps) == 0 or key_of(stamps) == key_of(holds):
|
|
974
|
+
return None
|
|
975
|
+
try:
|
|
976
|
+
form = entry_line({'cancellation': cancellation_record, 'stamps': stamps})
|
|
977
|
+
except Exception:
|
|
978
|
+
return None
|
|
979
|
+
tried = fork_carried(self._carried, True)
|
|
980
|
+
carry_on(tried, form + '\n')
|
|
981
|
+
if answer_of(tried, self._checking())['result']['summary']['problemFound']:
|
|
982
|
+
return None
|
|
983
|
+
return stamps
|
|
984
|
+
|
|
985
|
+
# --- the calls ---
|
|
986
|
+
|
|
987
|
+
def before(self, request, more=None):
|
|
988
|
+
"""Ask whether an action would be outside the slip. Nothing is
|
|
989
|
+
written. The answer holds only until the next stub is written: use
|
|
990
|
+
"act" to ask and act in one step.
|
|
991
|
+
|
|
992
|
+
request: {"action", and where they apply "amount", "with", "details"}.
|
|
993
|
+
more: {"when" (milliseconds since 1970, or a datetime), "terms",
|
|
994
|
+
"approval": {"record"}}.
|
|
995
|
+
Returns {"allowed", "problems", "breaches", "needs"}."""
|
|
996
|
+
|
|
997
|
+
def work(call):
|
|
998
|
+
proposal = self._proposal_from(call, call['when'] if call['when'] is not _ABSENT else self._now())
|
|
999
|
+
return self._while_stopped(check_before_read(lambda: self._standing(), proposal, self._checking()))
|
|
1000
|
+
|
|
1001
|
+
return self._calling(request, more, work)
|
|
1002
|
+
|
|
1003
|
+
def record(self, request, more=None):
|
|
1004
|
+
"""Write the stub for an action that was taken, and add it to the
|
|
1005
|
+
book. It does not ask first: use "act" for that. An action that was
|
|
1006
|
+
taken is recorded whether or not it was inside the slip, because
|
|
1007
|
+
the record is of what happened. But a stub is written only if the
|
|
1008
|
+
book still passes its check with it: one dated ahead of the clock,
|
|
1009
|
+
or before the stub ahead of it, or with an approval that does not
|
|
1010
|
+
fit, is refused, and nothing is written. The other side is asked to
|
|
1011
|
+
countersign only once the stub has passed that check, so a stub
|
|
1012
|
+
that is refused has gone nowhere.
|
|
1013
|
+
|
|
1014
|
+
more: "when" (when the action was taken; the clock by default),
|
|
1015
|
+
"terms" (the fingerprint of the service's terms relied on),
|
|
1016
|
+
"approval" ({"record": the person's signed approval of this
|
|
1017
|
+
action}), "countersign" (a function that asks the other side for
|
|
1018
|
+
its countersignature of the stub it is handed, and gives it, or
|
|
1019
|
+
None if it gives none).
|
|
1020
|
+
Returns {"record", "fingerprint", "seq", "line", "countersignature":
|
|
1021
|
+
{"accepted", "problem"}}. "countersignature.accepted" says whether a
|
|
1022
|
+
countersignature was given, checked and put with the stub. Where
|
|
1023
|
+
one was given and did not check, or asking for it failed, "problem"
|
|
1024
|
+
says why, and the stub is one-sided.
|
|
1025
|
+
Raises a Refusal, "record-not-sound", if the book would not pass its
|
|
1026
|
+
check with the stub; nothing is written."""
|
|
1027
|
+
|
|
1028
|
+
def work(call):
|
|
1029
|
+
self._write_waiting(self._now())
|
|
1030
|
+
when = call['when'] if call['when'] is not _ABSENT else self._now()
|
|
1031
|
+
if self._ahead_of_clock(when):
|
|
1032
|
+
raise _not_written('The stub was not written: it is dated ahead of the clock.')
|
|
1033
|
+
made = self._compose_stub(call, when)
|
|
1034
|
+
tried = self._fits_book(made, 'The stub was not written: with it, the book would not pass its check.')
|
|
1035
|
+
return self._commit(self._countersigned(made, call), made, tried)
|
|
1036
|
+
|
|
1037
|
+
return self._calling(request, more, work)
|
|
1038
|
+
|
|
1039
|
+
def act(self, request, perform, more=None):
|
|
1040
|
+
"""Ask first; if the action is allowed, take it and write its stub.
|
|
1041
|
+
|
|
1042
|
+
If the action is not allowed it is not taken and nothing is
|
|
1043
|
+
written. The action is taken now: a date handed in ("when") must be
|
|
1044
|
+
within 300 seconds of the writer's clock, or the action is not
|
|
1045
|
+
taken. Before the action is taken, its stub is written and checked
|
|
1046
|
+
with the whole book, so that the receipt is known to fit before
|
|
1047
|
+
there is anything to give a receipt for. Where the slip asks for a
|
|
1048
|
+
countersignature, the action is taken only if a way to ask for one
|
|
1049
|
+
("countersign") is given. If taking the action fails with an error,
|
|
1050
|
+
the error is passed on and no stub is written: there is then no
|
|
1051
|
+
receipt for whatever part of the action did happen, and the caller
|
|
1052
|
+
must deal with that. If taking the action never ends, the writer
|
|
1053
|
+
waits: it cannot know what happened.
|
|
1054
|
+
|
|
1055
|
+
perform: a function of no arguments that takes the action and gives
|
|
1056
|
+
its result. It must not call this writer: such a call is refused at
|
|
1057
|
+
once. It must be an ordinary function, not a coroutine function.
|
|
1058
|
+
more: as for "record"; "when" defaults to the clock and must be
|
|
1059
|
+
within 300 seconds of it.
|
|
1060
|
+
Returns {"done", "answer"} and, where the action was taken,
|
|
1061
|
+
"result" (what perform gave) and "stub" (as "record" gives it)."""
|
|
1062
|
+
|
|
1063
|
+
def work(call):
|
|
1064
|
+
self._write_waiting(self._now())
|
|
1065
|
+
when = call['when'] if call['when'] is not _ABSENT else self._now()
|
|
1066
|
+
answer = self._while_stopped(check_before_read(lambda: self._standing(), self._proposal_from(call, when), self._checking()))
|
|
1067
|
+
|
|
1068
|
+
def stop(problem):
|
|
1069
|
+
answer['allowed'] = False
|
|
1070
|
+
answer['problems'].append(problem)
|
|
1071
|
+
|
|
1072
|
+
if answer['allowed'] and self._ahead_of_clock(when):
|
|
1073
|
+
stop({'code': 'record-not-sound', 'message': 'The action is dated ahead of the clock.'})
|
|
1074
|
+
if answer['allowed'] and self._behind_clock(when):
|
|
1075
|
+
stop({
|
|
1076
|
+
'code': 'record-not-sound',
|
|
1077
|
+
'message': 'The action is dated behind the clock. An action is taken now, so its date may not be more than 300 seconds before the stub writer\'s clock. '
|
|
1078
|
+
'An action that was taken earlier is written with "record".',
|
|
1079
|
+
})
|
|
1080
|
+
# Said whether or not something else stands in the way too, so that whoever reads the answer sees all of it.
|
|
1081
|
+
if 'countersignature' in answer['needs'] and not callable(call['countersign']) and not any(b['code'] == 'countersignature-missing' for b in answer['breaches']):
|
|
1082
|
+
answer['allowed'] = False
|
|
1083
|
+
answer['breaches'].append({
|
|
1084
|
+
'code': 'countersignature-missing',
|
|
1085
|
+
'message': 'The slip asks for the other side to countersign this action, and the stub writer was given no way to ask for a countersignature.',
|
|
1086
|
+
})
|
|
1087
|
+
made = None
|
|
1088
|
+
tried = None
|
|
1089
|
+
if answer['allowed']:
|
|
1090
|
+
try:
|
|
1091
|
+
made = self._compose_stub(call, when)
|
|
1092
|
+
tried = self._fits_book(made, 'The stub for this action would not pass the check of the book, so the action was not taken.')
|
|
1093
|
+
except Exception as e:
|
|
1094
|
+
stop(problem_from(e))
|
|
1095
|
+
if not answer['allowed']:
|
|
1096
|
+
return {'done': False, 'answer': answer}
|
|
1097
|
+
outcome = self._run_inside(perform)
|
|
1098
|
+
stub = self._commit(self._countersigned(made, call), made, tried)
|
|
1099
|
+
return {'done': True, 'answer': answer, 'result': outcome, 'stub': stub}
|
|
1100
|
+
|
|
1101
|
+
return self._calling(request, more, work)
|
|
1102
|
+
|
|
1103
|
+
def book(self):
|
|
1104
|
+
"""The book as it now stands. To keep the book and the cancellations the
|
|
1105
|
+
writer holds beside it together, read both with snapshot()."""
|
|
1106
|
+
return self._text
|
|
1107
|
+
|
|
1108
|
+
def snapshot(self):
|
|
1109
|
+
"""The book and the cancellations the writer holds beside it, read
|
|
1110
|
+
together, so that nothing another thread does falls between the two:
|
|
1111
|
+
(book, cancellations). From another thread it waits for a call in
|
|
1112
|
+
progress; from inside an action the writer is taking, it reads at once."""
|
|
1113
|
+
return self._quietly(lambda: (self._text, copy.deepcopy(self._beside())))
|
|
1114
|
+
|
|
1115
|
+
def _quietly(self, read):
|
|
1116
|
+
"""Read the writer's state: at once from inside its own call, where
|
|
1117
|
+
nothing else can change it; otherwise once no call is in progress."""
|
|
1118
|
+
mark = _INSIDE.get()
|
|
1119
|
+
if (mark is not None and mark.writer is self and mark.running) or self._owner == threading.get_ident():
|
|
1120
|
+
return read()
|
|
1121
|
+
with self._lock:
|
|
1122
|
+
return read()
|
|
1123
|
+
|
|
1124
|
+
def check(self):
|
|
1125
|
+
"""What the record shows as it stands: the answer that a whole check
|
|
1126
|
+
of the book gives, with the cancellations the writer holds beside
|
|
1127
|
+
it. Nothing is read again. A copy of its own each time."""
|
|
1128
|
+
return self._in_turn(lambda: copy.deepcopy(self._standing()['result']))
|
|
1129
|
+
|
|
1130
|
+
def cancellations(self):
|
|
1131
|
+
"""The person's cancellations that the writer holds beside its book:
|
|
1132
|
+
those it was given when it was opened, and those of its own slip
|
|
1133
|
+
that it could not yet write into the book, each with the
|
|
1134
|
+
acknowledgement the writer signed for it; and a cancellation whose
|
|
1135
|
+
acknowledgement still waits to follow it into the book, with that
|
|
1136
|
+
acknowledgement. They live only in this writer. Keep them with the
|
|
1137
|
+
book, and hand them in again (options "cancellations") when a writer
|
|
1138
|
+
is opened again from the book: a writer opened without them knows
|
|
1139
|
+
nothing of them. Copies, each as it is handed to a check:
|
|
1140
|
+
{"cancellation", "stamps"?, "acknowledgements"?}. To keep them together
|
|
1141
|
+
with the book, read both with snapshot()."""
|
|
1142
|
+
return self._quietly(lambda: copy.deepcopy(self._beside()))
|
|
1143
|
+
|
|
1144
|
+
def add(self, entry):
|
|
1145
|
+
"""Add an entry that someone else made (a seal, a cancellation, a
|
|
1146
|
+
refusal) to the book. It is added only if the book still passes its
|
|
1147
|
+
check with it, and it is not dated ahead of the clock.
|
|
1148
|
+
|
|
1149
|
+
A cancellation is a special case: whatever is wrong with what stands
|
|
1150
|
+
beside it, a cancellation that the person signed is not lost.
|
|
1151
|
+
- If the entry cannot be added as it was handed over (a faulty
|
|
1152
|
+
time-stamp beside it, a member that cannot be written as a
|
|
1153
|
+
line), the cancellation is added with the time-stamps that pass
|
|
1154
|
+
their check, or by itself, without what else stood beside it,
|
|
1155
|
+
if the book passes its check with that.
|
|
1156
|
+
- If it cannot be added even so (a date ahead of the clock), but
|
|
1157
|
+
is signed with the passkey the slip names, the writer keeps it
|
|
1158
|
+
as the person's own copy and allows nothing more under the
|
|
1159
|
+
slip. It writes it into the book, at its next call, once the
|
|
1160
|
+
clock has reached its date.
|
|
1161
|
+
In both cases the call still fails, and the error says what was done.
|
|
1162
|
+
|
|
1163
|
+
Whenever the person's cancellation of this writer's own slip is
|
|
1164
|
+
handed to "add", the writer signs an acknowledgement: its agent's
|
|
1165
|
+
statement that it was handed the cancellation, and when. It is
|
|
1166
|
+
handed back, to be given to the person, and it follows the
|
|
1167
|
+
cancellation into the book. One cancellation is acknowledged once:
|
|
1168
|
+
the same acknowledgement is handed back each time, also by a writer
|
|
1169
|
+
opened again and given what "cancellations" handed back.
|
|
1170
|
+
|
|
1171
|
+
Returns {} or, for the person's cancellation of this writer's slip,
|
|
1172
|
+
{"acknowledgement": the record, or None where none could be signed}.
|
|
1173
|
+
Raises a Refusal, "record-not-sound", if the entry cannot be added as
|
|
1174
|
+
it was handed over. Nothing is added, except a cancellation by
|
|
1175
|
+
itself as set out above. Where the person's cancellation of this
|
|
1176
|
+
writer's slip was handed over, the error's "acknowledgement" holds
|
|
1177
|
+
the acknowledgement."""
|
|
1178
|
+
# What is handed over is read once, member by member, when it is
|
|
1179
|
+
# handed over: the members a line of a book lists, and the three of
|
|
1180
|
+
# a cancellation by their names. Each is copied as it is read. A
|
|
1181
|
+
# member that cannot be copied makes the entry one that cannot be
|
|
1182
|
+
# written as a line. The three members of a cancellation are read
|
|
1183
|
+
# first, each with room for a line of its own, so that nothing
|
|
1184
|
+
# handed over beside them can keep them from being read. The other
|
|
1185
|
+
# members share one line's room: once one of them cannot be copied,
|
|
1186
|
+
# the entry cannot be a line, and the rest are not read. Anything
|
|
1187
|
+
# but an object (a list, text, None) cannot be a line, and is not
|
|
1188
|
+
# written out.
|
|
1189
|
+
read = {}
|
|
1190
|
+
listed = None
|
|
1191
|
+
if isinstance(entry, Mapping):
|
|
1192
|
+
try:
|
|
1193
|
+
# A name that is a number (or True, False, None) is the text JavaScript
|
|
1194
|
+
# would give it; any other name makes the entry not plain data, but the
|
|
1195
|
+
# three members of a cancellation are still read.
|
|
1196
|
+
named = {}
|
|
1197
|
+
strange = False
|
|
1198
|
+
for name, item in entry.items():
|
|
1199
|
+
try:
|
|
1200
|
+
named[_js.property_key(name)] = item
|
|
1201
|
+
except TypeError:
|
|
1202
|
+
strange = True
|
|
1203
|
+
entry = named
|
|
1204
|
+
listed = _js.keys(entry)
|
|
1205
|
+
known = [name for name in ('cancellation', 'stamps', 'acknowledgements') if name in entry]
|
|
1206
|
+
rest = _copier()
|
|
1207
|
+
whole = True
|
|
1208
|
+
members = known + [name for name in listed if name not in known]
|
|
1209
|
+
if strange:
|
|
1210
|
+
# A name that is not plain data: the entry cannot be a line, though
|
|
1211
|
+
# its other members are read.
|
|
1212
|
+
listed = listed + [_STRANGE]
|
|
1213
|
+
for name in members:
|
|
1214
|
+
own = name in known
|
|
1215
|
+
if not own and not whole:
|
|
1216
|
+
read[name] = None
|
|
1217
|
+
continue
|
|
1218
|
+
try:
|
|
1219
|
+
read[name] = {'value': _copy_of(entry[name]) if own else rest(entry[name])}
|
|
1220
|
+
except Exception:
|
|
1221
|
+
read[name] = None
|
|
1222
|
+
if not own:
|
|
1223
|
+
whole = False
|
|
1224
|
+
except Exception:
|
|
1225
|
+
listed = []
|
|
1226
|
+
read.clear()
|
|
1227
|
+
|
|
1228
|
+
def member(name):
|
|
1229
|
+
return read.get(name)
|
|
1230
|
+
|
|
1231
|
+
# The entry is written out once, as text. The text is what is checked, and what is added.
|
|
1232
|
+
line = None
|
|
1233
|
+
try:
|
|
1234
|
+
if listed and all(member(name) is not None for name in listed):
|
|
1235
|
+
line = entry_line({name: member(name)['value'] for name in listed})
|
|
1236
|
+
except Exception:
|
|
1237
|
+
line = None # it cannot be a line of a book
|
|
1238
|
+
# A cancellation is also written out by itself, without what stands beside it.
|
|
1239
|
+
alone = None
|
|
1240
|
+
try:
|
|
1241
|
+
if member('cancellation') is not None:
|
|
1242
|
+
alone = entry_line({'cancellation': member('cancellation')['value']})
|
|
1243
|
+
except Exception:
|
|
1244
|
+
alone = None # not a record at all
|
|
1245
|
+
# And with its time-stamps alone, where more than those stands beside
|
|
1246
|
+
# it; and the time-stamps themselves, each as a line would hold it:
|
|
1247
|
+
# every one that can be written, from the first sixteen places of the
|
|
1248
|
+
# list, so that a faulty item beside a sound time-stamp does not lose it.
|
|
1249
|
+
stamped = None
|
|
1250
|
+
offered = []
|
|
1251
|
+
stamps = member('stamps') if alone is not None else None
|
|
1252
|
+
if stamps is not None:
|
|
1253
|
+
try:
|
|
1254
|
+
if len(listed) > 2:
|
|
1255
|
+
stamped = entry_line({'cancellation': member('cancellation')['value'], 'stamps': stamps['value']})
|
|
1256
|
+
except Exception:
|
|
1257
|
+
stamped = None
|
|
1258
|
+
if isinstance(stamps['value'], list):
|
|
1259
|
+
for item in stamps['value'][:_MAX_HANDED]:
|
|
1260
|
+
try:
|
|
1261
|
+
offered.append(_js.parse(canonical_json(item)))
|
|
1262
|
+
except Exception:
|
|
1263
|
+
pass # not a time-stamp
|
|
1264
|
+
# The acknowledgements handed over with it, as "cancellations" hands them back.
|
|
1265
|
+
handed = None
|
|
1266
|
+
acknowledgements = member('acknowledgements') if alone is not None else None
|
|
1267
|
+
try:
|
|
1268
|
+
if acknowledgements is not None and isinstance(acknowledgements['value'], list):
|
|
1269
|
+
handed = _js.parse(_js.stringify(acknowledgements['value']))
|
|
1270
|
+
except Exception:
|
|
1271
|
+
handed = None
|
|
1272
|
+
return self._in_turn(lambda: self._add(line, alone, stamped, offered, handed, listed))
|
|
1273
|
+
|
|
1274
|
+
def _add(self, line, alone, stamped, offered, handed, listed):
|
|
1275
|
+
slip = self._slip
|
|
1276
|
+
# The person's cancellation of this writer's own slip stops the
|
|
1277
|
+
# writer before anything else is done, the reading of the clock
|
|
1278
|
+
# included: whatever fails after this, no action is taken. The
|
|
1279
|
+
# acknowledgements handed over with it are sorted at once too, so
|
|
1280
|
+
# that a clock that fails loses none of them.
|
|
1281
|
+
early = None
|
|
1282
|
+
if alone is not None:
|
|
1283
|
+
candidate = _js.parse(alone)
|
|
1284
|
+
found = answer_of(self._carried, {**self._base, 'cancellations': [candidate]})['result']['held']
|
|
1285
|
+
one = found[0] if found else None
|
|
1286
|
+
if one and one['used'] and one['slip'] == slip:
|
|
1287
|
+
self._stopped = True
|
|
1288
|
+
early = {'candidate': candidate, 'one': one, 'others': self._sort_handed(one['fingerprint'], candidate['cancellation'], handed)}
|
|
1289
|
+
|
|
1290
|
+
# Where the writer holds a copy of this cancellation beside its book,
|
|
1291
|
+
# the copy takes the acknowledgements handed over now and, where the
|
|
1292
|
+
# book does not hold the cancellation, its sound time-stamps: before
|
|
1293
|
+
# anything is written into the book. A line of a book is not changed.
|
|
1294
|
+
# "keep": where the writer holds no copy, and the book does not hold
|
|
1295
|
+
# the cancellation, the writer keeps one, with the sound time-stamps.
|
|
1296
|
+
# True where the writer holds a copy afterwards.
|
|
1297
|
+
def take_handed(keep, in_book=None):
|
|
1298
|
+
if in_book is None:
|
|
1299
|
+
in_book = early is not None and early['one']['inBook'] is not None
|
|
1300
|
+
if not early:
|
|
1301
|
+
return False
|
|
1302
|
+
candidate, one, others = early['candidate'], early['one'], early['others']
|
|
1303
|
+
holds = next((k for k in self._kept if k['fingerprint'] == one['fingerprint']), None)
|
|
1304
|
+
at_opening = (
|
|
1305
|
+
None
|
|
1306
|
+
if holds
|
|
1307
|
+
else next(
|
|
1308
|
+
(h for h in self._given if _js.truthy(h) and _js.truthy(_member(h, 'cancellation')) and _member(h['cancellation'], 'payload') == _member(candidate['cancellation'], 'payload')),
|
|
1309
|
+
None,
|
|
1310
|
+
)
|
|
1311
|
+
)
|
|
1312
|
+
held = holds['entry'] if holds else at_opening
|
|
1313
|
+
if not held and (in_book or not keep or self._room() <= 0):
|
|
1314
|
+
return False
|
|
1315
|
+
stamps = (
|
|
1316
|
+
None
|
|
1317
|
+
if in_book
|
|
1318
|
+
else self._merged_stamps(candidate['cancellation'], held['stamps'] if held and 'stamps' in held and isinstance(held['stamps'], list) else [], offered)
|
|
1319
|
+
)
|
|
1320
|
+
if holds:
|
|
1321
|
+
if stamps:
|
|
1322
|
+
holds['entry'] = {**holds['entry'], 'stamps': stamps}
|
|
1323
|
+
holds['tried'] = False
|
|
1324
|
+
self._put_handed(holds['entry'], one['fingerprint'], others)
|
|
1325
|
+
elif at_opening:
|
|
1326
|
+
if stamps:
|
|
1327
|
+
at_opening['stamps'] = stamps
|
|
1328
|
+
item = next((u for u in self._unwritten if u['entry'] is at_opening), None)
|
|
1329
|
+
if item:
|
|
1330
|
+
item['tried'] = False
|
|
1331
|
+
self._put_handed(at_opening, one['fingerprint'], others)
|
|
1332
|
+
else:
|
|
1333
|
+
copied = {'cancellation': candidate['cancellation']}
|
|
1334
|
+
if stamps:
|
|
1335
|
+
copied['stamps'] = stamps
|
|
1336
|
+
self._put_handed(copied, one['fingerprint'], others)
|
|
1337
|
+
self._kept.append({'entry': copied, 'when': nan_time(one['content']['when']), 'fingerprint': one['fingerprint'], 'tried': False})
|
|
1338
|
+
return True
|
|
1339
|
+
|
|
1340
|
+
# The clock is read once for the whole call.
|
|
1341
|
+
try:
|
|
1342
|
+
at = self._now()
|
|
1343
|
+
except Exception as e:
|
|
1344
|
+
# The cancellation is kept, with its sound time-stamps and the
|
|
1345
|
+
# acknowledgements handed over with it, so that none is lost with
|
|
1346
|
+
# the call.
|
|
1347
|
+
holding = take_handed(True)
|
|
1348
|
+
# An acknowledgement of the agent's own, taken for a cancellation
|
|
1349
|
+
# the book holds, waits to follow it into the book, and is handed
|
|
1350
|
+
# back meanwhile, as one that could not be written at once does.
|
|
1351
|
+
fp = early['one']['fingerprint'] if early else None
|
|
1352
|
+
if early and early['one']['inBook'] is not None and fp in self._acknowledged and fp not in self._written:
|
|
1353
|
+
self._waiting[fp] = early['candidate']['cancellation']
|
|
1354
|
+
if early and early['one']['inBook'] is None and not holding:
|
|
1355
|
+
full = self._refused(
|
|
1356
|
+
'The clock failed, so nothing was written into the book. The cancellation is signed with the passkey the slip names, so the stub writer '
|
|
1357
|
+
'allows nothing more under that slip. It already holds as many cancellations beside its book as it can, so this one is not kept: hand it over again later.',
|
|
1358
|
+
)
|
|
1359
|
+
full.cause = e
|
|
1360
|
+
raise full from e
|
|
1361
|
+
raise
|
|
1362
|
+
take_handed(False)
|
|
1363
|
+
self._write_waiting(at)
|
|
1364
|
+
why = 'it cannot be written as a line of a book.'
|
|
1365
|
+
if line is not None:
|
|
1366
|
+
whole = self._take_line(line, at)
|
|
1367
|
+
if whole['fits']:
|
|
1368
|
+
# An added stub of this writer's own chain moves its place in the chain on.
|
|
1369
|
+
self._after = self._last_stub(whole['checked']['entries'])
|
|
1370
|
+
last = whole['checked']['entries'][-1]
|
|
1371
|
+
if last['kind'] == 'cancellation' and last.get('slip') == slip and last['verified'] == 'all':
|
|
1372
|
+
self._stopped = True
|
|
1373
|
+
return {'acknowledgement': self._acknowledge(last['fingerprint'], _js.parse(line)['cancellation'], True, at)}
|
|
1374
|
+
return {}
|
|
1375
|
+
why = whole['why']
|
|
1376
|
+
if alone is None:
|
|
1377
|
+
raise _not_written(f'The entry was not added: {why}')
|
|
1378
|
+
# The cancellation with its time-stamps, then with those of them that
|
|
1379
|
+
# pass their check, then by itself, where it was handed over with more.
|
|
1380
|
+
cancellation_record = _js.parse(alone)['cancellation']
|
|
1381
|
+
sound = self._merged_stamps(cancellation_record, [], offered)
|
|
1382
|
+
with_sound = entry_line({'cancellation': cancellation_record, 'stamps': sound}) if sound else None
|
|
1383
|
+
added = None
|
|
1384
|
+
tried = {line}
|
|
1385
|
+
for form in (stamped, with_sound, alone):
|
|
1386
|
+
if form is None or form in tried or added is not None:
|
|
1387
|
+
continue
|
|
1388
|
+
tried.add(form)
|
|
1389
|
+
if self._take_line(form, at)['fits']:
|
|
1390
|
+
added = form
|
|
1391
|
+
# Whether the person signed it, for this writer's own slip, and
|
|
1392
|
+
# whether the book now holds it: checked without any other cancellation.
|
|
1393
|
+
candidate = _js.parse(added if added is not None else alone)
|
|
1394
|
+
found = answer_of(self._carried, {**self._base, 'cancellations': [candidate]})['result']['held']
|
|
1395
|
+
one = found[0] if found else None
|
|
1396
|
+
own = bool(one and one['used'] and one['slip'] == slip)
|
|
1397
|
+
stops = ' It is signed with the passkey the slip names, so the stub writer allows nothing more under that slip.' if own else ''
|
|
1398
|
+
if own:
|
|
1399
|
+
self._stopped = True
|
|
1400
|
+
if added is not None:
|
|
1401
|
+
acknowledgement = self._acknowledge(one['fingerprint'], candidate['cancellation'], True, at) if own else None
|
|
1402
|
+
if added == stamped:
|
|
1403
|
+
how = 'The cancellation was added to the book with its time-stamps, without what else stood beside it.'
|
|
1404
|
+
elif added == with_sound:
|
|
1405
|
+
how = 'The cancellation was added to the book with those of its time-stamps that pass their check, without what else stood beside it.'
|
|
1406
|
+
else:
|
|
1407
|
+
how = 'The cancellation by itself was added to the book, without what stood beside it.'
|
|
1408
|
+
raise self._refused(f'The cancellation was not added as it was handed over: {why} {how}{stops}', acknowledgement)
|
|
1409
|
+
if own:
|
|
1410
|
+
in_book = one['inBook'] is not None
|
|
1411
|
+
acknowledgement = self._acknowledge(one['fingerprint'], candidate['cancellation'], in_book, at)
|
|
1412
|
+
holding = take_handed(True, in_book)
|
|
1413
|
+
if in_book:
|
|
1414
|
+
how = (
|
|
1415
|
+
' It was written into the book in this call, from the copy the stub writer held beside it, with the time-stamps that copy holds.'
|
|
1416
|
+
if early and early['one']['inBook'] is None
|
|
1417
|
+
else ''
|
|
1418
|
+
)
|
|
1419
|
+
raise self._refused(f'The book already holds this cancellation, so it was not added again.{how}{stops}', acknowledgement)
|
|
1420
|
+
if not holding:
|
|
1421
|
+
raise self._refused(
|
|
1422
|
+
f'The cancellation was not added: {why}{stops} The stub writer already holds as many cancellations beside its book as it can, so this one is not kept: hand it over again later.',
|
|
1423
|
+
acknowledgement,
|
|
1424
|
+
)
|
|
1425
|
+
raise self._refused(f'The cancellation was not added: {why}{stops}', acknowledgement)
|
|
1426
|
+
raise _not_written(f'The entry was not added: {why}')
|