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/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}')