src/lock/world.bend
ezx@1.10.0.0 · 577 lines · 0 definitions
1# lock/world: everything `ez lock` reads, as one value, and what the lock may
2# depend on in it. The planner (lock/plan.bend) is a pure function of a
3# World; the interpreter (lock/run.bend) gathers one, answering each question
4# the planner asks by IO, and executes the plan the planner returns. The
5# design is docs/rfc/ez-lock-planner.md.
6#
7# A World is the ledger's text, the committed `.bend` files, one answer per
8# package the planner asked about, and, under `--upgrade`, one answer per
9# question the upgrade asked (lock/up.bend). There is no field for the
10# environment, the clock, untracked files, `.ez/origins.toml` or BEND_LIB's
11# path, because the lock reads none of them. A package a dependency imports
12# by `<name>@<version>` adds two questions, asked only when the ledger does
13# not name it: the lock being rewritten, which already records every name it
14# resolved, and, for a name it does not record, what the hub says the name
15# names. Both are answers like any other, and enter the lock through their
16# verdicts.
17import Base
18import ezx@1.10.0.0/src/lock/lock.bend as L
19import ezx@1.10.0.0/src/lock/up.bend as Up
20import ezx@1.10.0.0/src/ledger/upgrade.bend as U
21import ezx@1.10.0.0/src/ledger/manifest.bend as M
22import ezx@1.10.0.0/src/pkg/pkg.bend as K
23import ezx@1.10.0.0/src/hub/hub.bend as Web
24import ezx@1.10.0.0/src/share/sha.bend as Sha
25import ezx@1.10.0.0/src/share/say.bend as Say
26
27# the command as it was run: `--upgrade`, the name `--package` gave ("" for
28# none), and the directory it runs in, which is the project root. A relative
29# path source is recorded as it was given, relative to that root, and every
30# question the lock asks about it names it anchored there, as the planner
31# computes it (`P.anchor.src`, `Up.anchor`).
32type Args is Data:
33 Args{upgrade: Bool, only: String, here: String}
34
35# one committed `.bend` file: its path and its text
36type Source is Data:
37 Source{at: String, text: String}
38
39# what `git ls-files '*.bend'` answered: the committed sources, or why git
40# could not list them
41type Listing is Data:
42 Listed{files: List<&2, Source>}
43 Unlisted{why: String}
44
45# how the interpreter came by a package's bytes. A tree already under
46# BEND_LIB, vendored or left by an earlier lock, was read from there. One that
47# was not was cloned at the rev the ledger pins, and weighed. A hub package was
48# served by the hub.
49type How is Data:
50 Lib{}
51 Clone{nar: String}
52 Served{}
53
54# a question the planner asks, answered by one IO action: a package's
55# manifest and the text of each file it names, where the source says to read
56# it from, and the hub is where a hub package is served; the hash the hub
57# says a `<name>@<version>` names, `GET <hub>/name/<nv>`; or the lock being
58# rewritten, ez.lock.toml as it is.
59type Ask is Data:
60 Pkg{hash: String, src: L.Src, hub: String}
61 Name{nv: String, hub: String}
62 Lock{}
63
64# the answer to one question: the manifest found and the text of each file it
65# names, in the manifest's order; what the hub said a name names, as it said
66# it; the lock's text, "" when there is none; or why IO could not answer
67type Answer is Data:
68 Got{how: How, manifest: String, srcs: List<&2, String>}
69 Miss{why: String}
70 Said{text: String}
71 Locked{text: String}
72
73# one question, answered
74type Reply is Data:
75 Reply{ask: Ask, answer: Answer}
76
77# everything `ez lock` reads. The ledger is ez.toml's text, or None when
78# there is no ez.toml. `ups` answers the upgrade's questions, and is empty for
79# a plain lock, which asks none.
80type World is Data:
81 World{args: Args, ledger: Maybe<&2, String>, listing: Listing,
82 replies: List<&2, Reply>, ups: List<&2, Up.Reply>}
83
84# what the planner makes of one answer: the files and their texts, once every
85# check passed, or why it refused them; or, for a name or the lock, the names
86# it resolves, each to its hash
87type Verdict is Data:
88 Ok{files: List<&2, K.Item>, srcs: List<&2, String>}
89 No{why: String}
90 Named{names: List<&2, L.Name>}
91
92# one package hash and its verdict
93type Judged is Data:
94 Judged{hash: String, verdict: Verdict}
95
96# the part of a World the lock may depend on: the ledger the lock is made
97# from, read, which under `--upgrade` is the one the upgrade leaves; why the
98# upgrade refuses, "" when it does not; the committed sources; and the verdict
99# on every package. A reply enters only through its verdict, so how the bytes
100# arrived does not, and bytes that failed a check enter only as the refusal
101# they caused.
102type Inputs is Data:
103 Inputs{ledger: M.Read, stop: String, listing: Listing, judged: List<&2, Judged>}
104
105# whether the command was `ez lock --upgrade`
106def args.upgrade(world: World) -> Bool:
107 World{args, _ledger, _listing, _replies, _ups} = world
108 Args{up, _only, _here} = args
109 up
110
111# the name `--package` gave, "" for none
112def args.only(world: World) -> String:
113 World{args, _ledger, _listing, _replies, _ups} = world
114 Args{_up, only, _here} = args
115 only
116
117# the directory the command runs in
118def here.of(args: Args) -> String:
119 Args{_up, _only, here} = args
120 here
121
122# the directory the lock runs in, which is the project root
123def args.here(world: World) -> String:
124 World{args, _ledger, _listing, _replies, _ups} = world
125 here.of(args)
126
127# the ledger's text
128def ledger.of(world: World) -> Maybe<&2, String>:
129 World{_args, ledger, _listing, _replies, _ups} = world
130 ledger
131
132# the lock's path, which is fixed
133def lockfile() -> String:
134 "ez.lock.toml"
135
136# what a question asks about, which is the key its answer is judged and
137# looked up under: a package's hash, a name, or the lock's path. A `0x` name,
138# a `<name>@<version>` and the lock's path are three different strings.
139def ask.hash(ask: Ask) -> String:
140 match ask:
141 case Pkg{hash, _src, _hub}:
142 hash
143 case Name{nv, _hub}:
144 nv
145 case Lock{}:
146 lockfile()
147
148# ---------------------------------------------------------------------------
149# judging one answer
150
151# whether every text hashes to the sum its file is recorded with. A list of
152# texts that is longer or shorter than the files is not the package.
153def sums.ok(fs: List<&2, K.Item>, ss: List<&2, String>) -> Bool:
154 match fs ss:
155 case Nil{} Nil{}:
156 True{}
157 case Nil{} Con{_h, _t}:
158 False{}
159 case Con{_f, _g} Nil{}:
160 False{}
161 case Con{f, g} Con{s, t}:
162 +rest = sums.ok(g, t)
163 Bool.and(String.eq(Sha.hex(s), K.file.sum(f)), rest)
164
165# each file with its text, as it is laid
166def laid(fs: List<&2, K.Item>, ss: List<&2, String>) -> List<&2, Source>:
167 match fs ss:
168 case Nil{} Nil{}:
169 []
170 case Nil{} Con{_s, _t}:
171 []
172 case Con{_f, _g} Nil{}:
173 []
174 case Con{f, g} Con{s, t}:
175 Source{K.file.at(f), s} <> laid(g, t)
176
177# a laid file weighed by its own text
178def weigh(src: Source) -> K.Item:
179 Source{at, +text} = src
180 K.Item{at, Sha.hex(text)}
181
182# every laid file weighed by its own text
183def weighs(ls: List<&2, Source>) -> List<&2, K.Item>:
184 match ls:
185 case []:
186 []
187 case h <> t:
188 weigh(h) <> weighs(t)
189
190# the manifest of the files laid, weighed by their own texts, which is the
191# manifest a tree is laid with
192def manifest(+ls: List<&2, Source>) -> String:
193 K.manifest_of(K.files_of(weighs(ls)))
194
195# the `0x` name of that manifest
196def named(+ls: List<&2, Source>) -> String:
197 K.hash_of(K.files_of(weighs(ls)))
198
199# the verdict once the files, weighed by their own texts, were asked whether
200# they hash to the name. Their manifest is the one the tree is laid with, and
201# the manifest that was read hashes to the name already, so a package whose
202# manifest is not written the way ez writes one (a line per file, in path
203# order, each file once) is refused here rather than laid under a name its
204# laid manifest does not hash to.
205def body.laid(ok: Bool, +hash: String, fs: List<&2, K.Item>, ss: List<&2, String>) -> Verdict:
206 match ok:
207 case True{}:
208 Ok{fs, ss}
209 case False{}:
210 No{"ez: " ++ hash ++ ": its manifest is not written the way its name is taken" ++
211 " (one line per file, in path order, each file once)"}
212
213# the verdict once the texts were weighed against the manifest
214def body.sums(ok: Bool, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict:
215 match ok:
216 case True{}:
217 body.laid(String.eq(hash, named(laid(fs, ss))), hash, fs, ss)
218 case False{}:
219 No{"ez: " ++ hash ++ ": a file does not match its manifest"}
220
221# the verdict once each path was asked whether it escapes the package
222def body.escape.go(clean: Bool, +bad: String, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict:
223 match clean:
224 case True{}:
225 body.sums(sums.ok(fs, ss), hash, fs, ss)
226 case False{}:
227 No{"ez: " ++ hash ++ ": its manifest escapes the package (" ++ bad ++ ")"}
228
229# the verdict once no path was found to escape the package
230def body.escape(+bad: String, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict:
231 body.escape.go(String.is_empty(bad), bad, hash, fs, ss)
232
233# the verdict once the manifest was known to be the one the hash names
234def body.named(ok: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
235 match ok:
236 case True{}:
237 +fs = L.manifest.files(String.lines(manifest))
238 body.escape(K.escaped(fs), hash, fs, ss)
239 case False{}:
240 No{"ez: " ++ hash ++ ": its manifest does not hash to its name"}
241
242# a package's bytes, checked the same way whatever they came from: the
243# manifest is the one the `0x` name is the digest of, no path leaves the
244# package, each text hashes to its file's sum, and the files, weighed by their
245# own texts, hash to the name, which is `ez fetch`'s check too. A tree under
246# BEND_LIB is checked against its name like any other, so a stale or edited
247# tree there is refused rather than locked.
248def body(+hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
249 body.named(Web.matches(L.want(hash), manifest), hash, manifest, ss)
250
251# a git package that arrived with no files is refused, never locked as an
252# empty table
253def git.body(empty: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
254 match empty:
255 case True{}:
256 No{"ez: " ++ hash ++ ": its manifest names no files"}
257 case False{}:
258 body(hash, manifest, ss)
259
260# a git package's bytes under the rule its name was taken by. Since bend
261# 2.0.27 a package holds the LICENSE files beside its sources, and the walk
262# takes them along. A ledger written before names the same checkout without
263# them, and that package is the checkout's too: when the walk's manifest does
264# not hash to the name, the package without its LICENSE files is judged
265# instead, and locked when that is the one the name is the digest of.
266def git.rule(named: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
267 match named:
268 case True{}:
269 git.body(List.is_empty(&2, K.Item, L.manifest.files(String.lines(manifest))),
270 hash, manifest, ss)
271 case False{}:
272 +fs = L.manifest.files(String.lines(manifest))
273 +bs = K.bare(fs)
274 git.body(List.is_empty(&2, K.Item, bs), hash, K.manifest.lines(bs),
275 K.bare.srcs(fs, ss))
276
277# a git package's bytes once its checkout was weighed. A tree read from
278# BEND_LIB is judged as a clone that weighed to the narHash the ledger
279# records, so the same bytes lock the same way whichever way they came.
280def git.weighed.go(same: Bool, +why: String, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
281 match same:
282 case True{}:
283 git.rule(Web.matches(L.want(hash), manifest), hash, manifest, ss)
284 case False{}:
285 No{why}
286
287def git.weighed(+why: String, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
288 git.weighed.go(String.is_empty(why), why, hash, manifest, ss)
289
290# a git package, by how it arrived
291def git.how(
292 how: How,
293 +hash: String,
294 +url: String,
295 +rev: String,
296 +nar: String,
297 +manifest: String,
298 +ss: List<&2, String>
299) -> Verdict:
300 match how:
301 case Lib{}:
302 git.weighed(L.nar.why(nar, nar, url, rev), hash, manifest, ss)
303 case Clone{got}:
304 git.weighed(L.nar.why(nar, got, url, rev), hash, manifest, ss)
305 case Served{}:
306 No{"ez: " ++ hash ++ " is a git package and was not read from git"}
307
308# a hub package, by how it arrived. Only the hub serves one.
309def hub.how(how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
310 match how:
311 case Lib{}:
312 No{"ez: " ++ hash ++ " is a hub package and was not served by the hub"}
313 case Clone{_got}:
314 No{"ez: " ++ hash ++ " is a hub package and was not served by the hub"}
315 case Served{}:
316 body(hash, manifest, ss)
317
318# a workspace member's package, by how it arrived. Its tree is the committed
319# one, read from the workspace as a tree under BEND_LIB is read, and judged
320# by its bytes alone, as any tree there is; only the hub could serve it
321# under another rule.
322def member.how(how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
323 match how:
324 case Lib{}:
325 body(hash, manifest, ss)
326 case Clone{_got}:
327 body(hash, manifest, ss)
328 case Served{}:
329 No{"ez: " ++ hash ++ " is a workspace member and was not read from the workspace"}
330
331# a package's answer against where the ledger says it comes from
332def accept.src(src: L.Src, how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict:
333 match src:
334 case L.Hub{}:
335 hub.how(how, hash, manifest, ss)
336 case L.Git{url, rev, _entry, _root, nar, _tag}:
337 git.how(how, hash, url, rev, nar, manifest, ss)
338 case L.Member{_path, _entry}:
339 member.how(how, hash, manifest, ss)
340
341# why a hub package could not be read. One the ledger names is explained the
342# way a hub miss always was. One it does not name is said to be unnamed, since
343# that, and not the hub, is what a person has to fix.
344def hub.why(named: Bool, +hash: String, +hub: String, +why: String) -> String:
345 match named:
346 case True{}:
347 Say.hub.explain(hash, L.url_of(hub, hash, "manifest"), why)
348 case False{}:
349 "ez: " ++ hash ++ " is imported, ez.toml does not name it, and the hub at " ++
350 hub ++ " did not serve it (" ++ why ++ "). A package taken from git is recorded with `ez add`."
351
352# why IO could not answer, said for where the package comes from
353def miss.src(src: L.Src, named: Bool, +hash: String, +hub: String, +why: String) -> String:
354 match src:
355 case L.Hub{}:
356 hub.why(named, hash, hub, why)
357 case L.Git{_url, _rev, _entry, _root, _nar, _tag}:
358 why
359 case L.Member{_path, _entry}:
360 why
361
362# what the hub said a name names, once it is known whether the answer is a
363# `0x` name
364def said.ok(ok: Bool, +nv: String, +hash: String, +hub: String) -> Verdict:
365 match ok:
366 case True{}:
367 Named{[L.Name{nv, hash}]}
368 case False{}:
369 No{"ez: the hub at " ++ hub ++ " names no package " ++ nv ++ " (it answered `" ++
370 hash ++ "`)"}
371
372# the hub's answer for a name: the `0x` name it gives, trimmed as bend trims
373# it, or a refusal when it gives none
374def said(+nv: String, +hub: String, +text: String) -> Verdict:
375 +hash = String.trim(text)
376 said.ok(L.hash.ok(hash), nv, hash, hub)
377
378# the lock being rewritten: every name it records that bend would read back
379def locked(text: String) -> Verdict:
380 Named{L.names.read(text)}
381
382# one answer judged
383def accept(+orgs: List<&2, L.Origin>, +hub: String, +hash: String, answer: Answer) -> Verdict:
384 match answer:
385 case Miss{why}:
386 No{miss.src(L.origin(orgs, hash), L.known(orgs, hash), hash, hub, why)}
387 case Got{how, manifest, ss}:
388 accept.src(L.origin(orgs, hash), how, hash, manifest, ss)
389 case Said{text}:
390 said(hash, hub, text)
391 case Locked{text}:
392 locked(text)
393
394# every reply judged, in the order they were given
395def judge(+orgs: List<&2, L.Origin>, +hub: String, rs: List<&2, Reply>) -> List<&2, Judged>:
396 match rs:
397 case []:
398 []
399 case Reply{ask, answer} <> t:
400 +hash = ask.hash(ask)
401 Judged{hash, accept(orgs, hub, hash, answer)} <> judge(orgs, hub, t)
402
403# the names a verdict resolves: those of a name's or the lock's answer, and
404# none for a package
405def verdict.names(verdict: Verdict) -> List<&2, L.Name>:
406 match verdict:
407 case Ok{_fs, _ss}:
408 []
409 case No{_why}:
410 []
411 case Named{ns}:
412 ns
413
414# every name the answers resolve, in the order they were given. The lock's
415# answer is asked for before any name's, so a name it records is never put
416# to the hub.
417def names.of(js: List<&2, Judged>) -> List<&2, L.Name>:
418 match js:
419 case []:
420 []
421 case Judged{_h, v} <> t:
422 List.append(&2, L.Name, verdict.names(v), names.of(t))
423
424# the ledger, read. A missing one is refused as a ledger that does not
425# parse: only `ez init` makes a ledger, and a lock of none is not a lock.
426def parsed(ledger: Maybe<&2, String>) -> M.Read:
427 match ledger:
428 case None{}:
429 M.Bad{"no ez.toml here; ez lock works on a project's ledger, and ez init makes one"}
430 case Some{text}:
431 M.parse(text)
432
433# the origins a ledger records
434def origins(+ledger: Maybe<&2, String>) -> List<&2, L.Origin>:
435 L.ledger.read(parsed(ledger))
436
437# the dependency ez.toml names `name`, as `M.dep` finds it
438def dep(world: World, name: String) -> M.Dep:
439 World{_args, ledger, _listing, _replies, _ups} = world
440 M.dep(parsed(ledger), name)
441
442# the hub a ledger names
443def ledger.hub(+ledger: Maybe<&2, String>) -> String:
444 M.hub_of(parsed(ledger))
445
446# ---------------------------------------------------------------------------
447# the ledger the lock is made from
448
449# a plain lock's is ez.toml as it is. An upgrade's is what the upgrade
450# decides on its answers: the ledger it writes, or its refusal, or the
451# questions it still has.
452def next.up(up: Bool, +only: String, +ledger: Maybe<&2, String>, ups: List<&2, Up.Reply>) -> Up.Next:
453 match up:
454 case False{}:
455 Up.plain(parsed(ledger))
456 case True{}:
457 Up.next(only, parsed(ledger), ups)
458
459# what the upgrade decides on a World, or nothing around a plain lock
460def next(args: Args, +ledger: Maybe<&2, String>, ups: List<&2, Up.Reply>) -> Up.Next:
461 Args{up, +only, _here} = args
462 next.up(up, only, ledger, ups)
463
464# the origins the lock reads packages by
465def next.origins(+nx: Up.Next) -> List<&2, L.Origin>:
466 L.ledger.read(Up.next.read(nx))
467
468# the hub it names
469def next.hub(+nx: Up.Next) -> String:
470 M.hub_of(Up.next.read(nx))
471
472# a tree the upgrade checked out at a new rev, as the answer to the package
473# question the lock would ask about it: a clone, weighed
474def laid.reply(laid: Up.Laid, +orgs: List<&2, L.Origin>, +hub: String) -> Reply:
475 Up.Laid{+hash, nar, manifest, srcs} = laid
476 Reply{Pkg{hash, L.origin(orgs, hash), hub}, Got{Clone{nar}, manifest, srcs}}
477
478def laid.replies(ls: List<&2, Up.Laid>, +orgs: List<&2, L.Origin>, +hub: String) -> List<&2, Reply>:
479 match ls:
480 case []:
481 []
482 case h <> t:
483 laid.reply(h, orgs, hub) <> laid.replies(t, orgs, hub)
484
485# the package answers the lock reads: the trees the upgrade checked out, then
486# every answer the World holds
487def replies.of(+nx: Up.Next, replies: List<&2, Reply>) -> List<&2, Reply>:
488 List.append(&2, Reply,
489 laid.replies(Up.next.trees(nx), next.origins(nx), next.hub(nx)), replies)
490
491# every package answer judged against the ledger the lock is made from
492def judged.of(+nx: Up.Next, replies: List<&2, Reply>) -> List<&2, Judged>:
493 List.append(&2, Judged,
494 judge(next.origins(nx), next.hub(nx),
495 laid.replies(Up.next.trees(nx), next.origins(nx), next.hub(nx))),
496 judge(next.origins(nx), next.hub(nx), replies))
497
498# every committed source with each hash the upgrade moved rewritten in its
499# imports, as the upgrade writes it
500def sources.swap(+ss: List<&2, U.Swap>, fs: List<&2, Source>) -> List<&2, Source>:
501 match fs:
502 case []:
503 []
504 case Source{at, text} <> t:
505 Source{at, U.reimport.many(ss, text)} <> sources.swap(ss, t)
506
507def listing.swap(+ss: List<&2, U.Swap>, listing: Listing) -> Listing:
508 match listing:
509 case Listed{fs}:
510 Listed{sources.swap(ss, fs)}
511 case Unlisted{why}:
512 Unlisted{why}
513
514# the committed sources as the lock reads them: as the upgrade leaves them,
515# which is as they are when no hash moved, and always for a plain lock
516def listing.moved(ss: List<&2, U.Swap>, listing: Listing) -> Listing:
517 match ss:
518 case []:
519 listing
520 case h <> t:
521 listing.swap(h <> t, listing)
522
523def listing.of(+nx: Up.Next, listing: Listing) -> Listing:
524 listing.moved(Up.next.swaps(nx), listing)
525
526# what a World is, as far as the lock may tell, once the upgrade decided
527def inputs.of(+nx: Up.Next, listing: Listing, replies: List<&2, Reply>) -> Inputs:
528 Inputs{Up.next.read(nx), Up.next.stop(nx), listing.of(nx, listing),
529 judged.of(nx, replies)}
530
531# what a World is, as far as the lock may tell
532def inputs(world: World) -> Inputs:
533 World{args, +ledger, listing, replies, ups} = world
534 inputs.of(next(args, ledger, ups), listing, replies)
535
536# ---------------------------------------------------------------------------
537# a fresh clone's World
538
539# one answer as a fresh clone would have it: a tree read from BEND_LIB is
540# instead cloned at the rev the ledger pins and weighed to the narHash the
541# ledger records. Anything else is what it was.
542def reclone.how(how: How, +src: L.Src) -> How:
543 match how:
544 case Lib{}:
545 Clone{L.src.nar(src)}
546 case Clone{got}:
547 Clone{got}
548 case Served{}:
549 Served{}
550
551def reclone.one(+orgs: List<&2, L.Origin>, +hash: String, answer: Answer) -> Answer:
552 match answer:
553 case Miss{why}:
554 Miss{why}
555 case Got{how, manifest, ss}:
556 Got{reclone.how(how, L.origin(orgs, hash)), manifest, ss}
557 case Said{text}:
558 Said{text}
559 case Locked{text}:
560 Locked{text}
561
562# every answer as a fresh clone would have it. A tree the upgrade checked
563# out is a clone already.
564def reclone.all(+orgs: List<&2, L.Origin>, rs: List<&2, Reply>) -> List<&2, Reply>:
565 match rs:
566 case []:
567 []
568 case Reply{+ask, answer} <> t:
569 Reply{ask, reclone.one(orgs, ask.hash(ask), answer)} <> reclone.all(orgs, t)
570
571# the World a fresh clone of the same commit gathers: the same ledger, the
572# same committed sources, and every tree it would have read from BEND_LIB
573# cloned and weighed instead, since a clone has nothing there
574def reclone(world: World) -> World:
575 World{+args, +ledger, listing, replies, +ups} = world
576 World{args, ledger, listing,
577 reclone.all(next.origins(next(args, ledger, ups)), replies), ups}