bat-cli 0.32.0

Blockchain Auditor Toolkit (BAT)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# The contract map — audit of the implementation

An audit of commits `473b13f`, `6424d8b`, `fcc9914` and `80ee20a` on `feature/architecture-map`
against `docs/contract-map-design.md`, `docs/contract-map-plan.md` and this repository's rules:
no silent fallbacks, every fix ships a test, comments say why. It was done by reading the code,
running the two targeted test modules (`map_test`, 9 green; `folding_test`, 4 green) and
`map --dry-run` on Ascend and Everlong. Nothing was changed and nothing was deployed. Findings
are ordered by what they cost the auditor, and §9 says which of them should be fixed before this
ships.

---

## 1. The reversed-edge layering is sound — and it is the design's levelling

The question was whether feeding `layout_graph` the reversed edges, rooted at a sink, is the same
as longest-path-to-a-sink levelling. It is, and the reason is in `assign_layers`
(`layout.rs:369`): Kahn's queue is seeded with the root **and every other in-degree-0 node**, the
longest-path relaxation then runs over the whole topological order, and every node the edges
never reach still gets a layer (`or_insert(0)`). On the reversed graph the in-degree-0 nodes are
exactly the original graph's sinks, so a node's layer is the longest path from *any* sink to it —
which is longest-path-to-a-sink, drawn left to right instead of bottom to top. Ascend's four
sinks (`Treasury`, `StakerDistributor`, `StakingQuoteOracle`, `YieldExitReserve`) all land in
column 0, and the callers that only reach `Treasury` are layered, not dropped. My worry that a
single root would strand the other sinks' callers does not apply.

What the root does decide is cycle breaking: `split_back_edges` starts its DFS at the root and
then at every remaining node in `nodes` order, so which edge of a cycle is drawn as the back edge
depends on the alphabetically-first sink. That is deterministic and acceptable; it is just not
dot's "from the top-level callers" choice, and on Everlong it may draw `FLAMM → MMRouter` red
where a reader would expect `MMRouter → FLAMM`. Worth one line in the legend ("the dashed red
arrow is the one the layout had to reverse"), not a change.

Two things the design asked of the levelling are not done, both cosmetic: column 0 is ordered by
the barycentre sweep, not by in-degree from the centre out, and the columns are top-aligned (the
deploy's rule) rather than centred. Neither affects correctness.

**The gap that does matter is in the drawing, not the layering.** `layout_graph` reserves a
corridor for every edge that skips a layer (`insert_bend_points`) and returns it in
`GraphLayout.routes` — and `draw` never reads `routes`. The commit message says "I rely on
`layout_graph`'s own routes"; the code relies on nothing. For an edge from column 3 to column 0,
`gutter = min(from.layer, to.layer)` puts the lane in the gutter right of the callee and the
first leg runs horizontally **from the caller's left edge across columns 1 and 2 at `from_y`** —
over whatever boxes sit there. That is the "arrows cross several boxes" the auditor started
from, now with straight lines. The barycentre ordering reduces how often a box sits on that y;
it does not prevent it, and the reserved corridor sits empty beside it. §4 says what to draw.

## 2. The batching: no race, one leak, and the wrong failure semantics

**Keys.** Markers are keyed `(arrow index, point index)` into one `BTreeMap`, filled from a
`JoinSet` whose tasks return their own key, so there is no ordering assumption and no race: a
task's result carries its identity. Connectors resolve both ends through that map and the legs
of `plan_lane_arrow` only ever reference the arrow's own points. Correct.

**The leak, and the semantics.** Every batch is drained with
`joined.map_err(..)??` — the first failed task returns `Err` from `draw` **while the other tasks
of the same `JoinSet` are still in flight**. Dropping a `JoinSet` aborts its tasks; a task
aborted between `send()` and the response has already created its item. So a single failed
marker leaves an unknown number of markers and boxes on the board, none of them reported, and
the frame URL is never printed (it is printed after the connectors). The same shape is in
`storage::draw` after `80ee20a`. This contradicts the repository's canonical rule — one error
does not take the batch down, the failed item is named with its reason, the rest completes — and
it is the rule `draw_cluster` already implements (`auto_deploy.rs:1195`: "One frame failing must
not cost the thirty that worked").

So the answer to "is `continue` right?" is: today the `continue` on a missing marker is dead code,
because a missing marker means the marker batch already returned `Err`. Once the batch is fixed
to run to completion it becomes the right behaviour **only if it is reported**: collect
`Vec<(String, Report)>` per batch (the item's name — `box StakingQuoteOracle`, `marker 3 of
YieldRouter → AscendLaunchpad`), skip the connectors whose ends are missing and add one line per
skipped arrow, print the frame URL, print the failures, and return `Err` at the end. Never delete
anything (design §8). That is one change in `draw` and the mirror of it in `storage`; it ships
with a test on a small harness that fakes one failed marker and asserts the other legs are still
planned and the skipped arrow is named.

**One more sequential wave than there needs to be — two, in fact.** `draw` awaits the legend
`JoinSet` to completion *before spawning the boxes*, awaits the boxes and images, *then* spawns
the markers, then the connectors: frame → legend → boxes+images → markers → connectors, five
waves. The plan's graph has three: frame → everything whose only reference is the frame (legend,
boxes, images, **markers**) → connectors. A marker's position comes from `plan_lane_arrow` over
box coordinates `prepare` already fixed, so nothing about a marker waits on a box id; the
coordinator's own question ("can the markers go up with the boxes?") is answered yes by the code's
own data flow, and the code does not do it. Cost: two extra round trips of latency plus the
budget-idle gap while each small wave drains. On Ascend's 162 calls at 11.8 s the run is at 13.7
calls a second against the 16.7 ceiling; the two waves are the likeliest explanation for most of
that gap.

## 3. The lane arithmetic

`pitch = min(span / (n + 1), LANE_PITCH)` with `span = |from_x − to_x|` is right in shape — n
lanes at equal spacing strictly inside the gap, never outside it — and wrong in two details.

**The origin is per edge, not per gutter.** `lane_x = to_x + pitch·(order + 1)` measures from
*this edge's callee's right edge*. Two edges in one gutter whose callees have different widths
(`Treasury` and `StakingQuoteOracle` are 8 and 18 characters) measure from different origins, so
their lanes are not on one grid: lane 2 of one can sit 20 units from lane 1 of the other, inside
a 16-unit stroke, and read as one line — the exact defect lanes exist to remove. The origin must
be the gutter's: the widest right edge of the column, which the layout knows (`layer_widths`), and
`span` the gutter's width (`gutter_x`), not the pair's.

**Narrow gutters are not handled; they are silently squeezed.** With the default
`gutter_x = 550` and stroke 16, `pitch` falls below one stroke width at `n > 550/16 ≈ 33` and
below the "five strokes" the constant promises at `n > 6`. Everlong's `MMRouter` gutter carries
eleven arrows today, so its lanes are 46 units apart — legible, but already under the stated
rule, and the code says nothing. The fix belongs in `prepare`, which is the point of having one:
layers depend only on the edges, so count the arrows per gutter *before* calling `layout_graph`
and pass `gutter_x = max(550, 2·margin + (n − 1)·LANE_PITCH)` — the deploy's own formula
(`diagram-deploy-design.md` §13). Then `min(.., LANE_PITCH)` never binds and the constant means
what its comment says. Test: a fixture with twelve arrows into one callee yields a gutter of
`2·margin + 11·80` and twelve distinct lane x's at exactly 80 apart.

**The lane order uses end y only.** `crossing.sort_by(to_y)`; the deploy's rule, which the
comment claims to mirror, is `(start y, end y)` — and it is the pair that makes non-inverted
arrows provably disjoint. With end y alone, two arrows to the same callee from callers at
different heights get an arbitrary (insertion) order, and one's horizontal leg can cut the other's
vertical. Sort by `(to_y, from_y)` — the callee side first because that is the side the lanes
are nearest to — and pin it with a test that counts leg crossings on a four-arrow fixture.

**Skip edges.** For an edge spanning several columns `span` is the whole distance, `pitch`
saturates at 80 and the lane hugs the callee's gutter while the first leg crosses the columns in
between (§1). The corridor `routes[i]` gives the x of the reserved slot in each skipped column;
the arrow should be drawn as the chain the design specified — into the gutter lane, across to
the corridor, down the corridor, into the next gutter — at `3 + 2s` legs. Until it is, the
estimate's "every arrow is three legs" is true and the picture is wrong for those edges; the
honest interim is to print how many arrows skip a column, so the number is on the terminal.

## 4. What the estimate now says that is not true

- **The legend is not counted.** `draw` creates four `create_label` shapes for the legend;
  `ApiCalls` has no field for them. Ascend's printed `158` is really 162.
- **The total printed at the end is the plan, not a measurement.** `"({} API call(s)"` prints
  `calls.total()`; nothing in `MiroClient` counts requests. The plan's whole verification step —
  "the real frame's call count must equal the dry run's prediction" — cannot be run. Add a
  `calls: AtomicU32` beside `retries` and `budget_waits`, incremented in `execute` per attempt,
  and print both numbers: `planned 162, made 162`. When they differ, that is the bug the plan
  exists to catch.
- **`retries` undercounts.** `execute` has two retry paths; only the HTTP one (`429`/`5xx`)
  increments the counter. A transport-error retry (`:300`) is a retry too. Minor, but the number
  is printed as if it were complete.
- **The legend has four entries for five relation kinds.** `Relation::Creates` is drawn purple
  (`#8e44ad`) and appears in no legend; `padding_y` still reserves four rows. A reader who sees a
  purple arrow has nothing to decode it with, which is the one thing a legend is for.
- **`--with-boundaries` draws boxes for the outside *and* the panel.** The design made the
  outside a panel *instead of* boxes (§8: "a panel and not boxes", and the code's own doc comment
  on `outside_panel` says the same). Today a boundary name becomes a node in `layout_map`
  (`names` come from every edge end), gets a box, gets arrows, *and* is listed in the panel —
  Ascend goes from 16 to 29 boxes and 158 to 354 calls with the flag. Without the flag the panel
  is empty, because `outside_panel` reads `Boundary` edges that only exist with the flag. So the
  panel never does its job: it is drawn only when the boxes make it redundant. The fix is in
  `relations`/`prepare`: always collect the outside into the panel, and never make it an edge.
- **The frame has no right margin.** `width = layout.frame_width + MAP_MARGIN + beside_width`,
  with the panels placed at `panel_left = layout.frame_width + MAP_MARGIN`; the widest panel ends
  exactly on the frame border. One more `MAP_MARGIN`.

## 5. Silent drops — the rule this repository holds hardest

- **A deployable contract with no relation is simply not on the map.** `layout_map` takes its
  node set from the edges, so `AscendCoreToken` (without the flag), `BurnSink`, `FLAMMProxy`,
  `VaultPay` are absent, and nothing says so. The design's §7 asked for a "not connected in
  scope" strip; at minimum the report must list them, as it lists the untyped receivers.
- **`new` of vendored or unknown code is dropped, and the comment says otherwise.**
  `created_by` filters `!target.vendored` and its doc comment says such a `new` "is listed with
  the untyped receivers instead" — `untyped_receivers` reads `unresolved_calls` and
  `unknown_external_calls` only, never `X.constructor` callees. An in-scope factory doing
  `new ERC1967Proxy(..)` or `new BeaconProxy(..)` — the common case — vanishes. Either list it
  (the comment's promise) or make it an `Outside` entry in the panel ("creates: ERC1967Proxy").
- **An abstract contract of this project with no deployable child is classified `Open` and then
  dropped.** `classify_type` returns `Open(name)` (a question, per its comment); `record` then
  drops it because `folded.contains(to)`. The question disappears. Rare, but it is exactly the
  "says it is a question, then says nothing" shape the rule forbids.
- **`hosts` keys by name.** Two in-scope contracts with one name (a `Treasury` in `src/` and in
  `test/`) merge their libraries. Known from the design's edge cases; not handled; should be
  listed in the report when it happens rather than silently merged.
- **The `or_else(find by name)` in `relations`' `sources`** is unreachable (every name in
  `hosted_by` exists in `contracts`), but it is a first-match-by-name fallback in a codebase whose
  CLAUDE.md says never to resolve a name that way. Delete it; a dead fallback is the next
  person's live one.

## 6. Tests: what shipped without one

`folding_test` covers `hosts` (direct, transitive, abstract) and the `Creates` ordering. Not
covered, and each is a rule in this repository: `plan_lane_arrow` — pure, the plan asked for a
pinning test that its anchors equal `draw_lane_arrow`'s, and there is none, so the two can drift
exactly as `line_anchor` once did (design doc §18); `created_by` resolving through the caller's
file (the "three `ProxyAdmin`s" the comment cites); `matrix` (marks, the `→ n, ← m` sums, the
under-three guard); `outside_panel`; `ApiCalls` against what `draw` actually issues (the legend
would have failed it); `layout_map`'s root choice and the reversed layering on a two-sink
fixture; the lane pitch and order (§3). `a_created_and_called_pair_keeps_the_call` asserts enum
order only — it does not build a created-and-called pair and check `relations` keeps the call.

## 7. Smaller things

- **The matrix is alphabetical, not in the diagram's order.** `names.sort()`; the design (§8)
  and the code's own doc comment ("in the same order, so the two halves of the frame can be read
  against each other") say layout order. In layout order the above/below-diagonal reading works
  and a red arrow is a below-diagonal mark; alphabetically it is noise.
- The ERC-20 family is not merged in the panel (design §8); `IERC20` and `IERC20Metadata` are
  two lines reached by the same callers.
- Node boxes carry no `called by · calls` line, no group tint, no amber border, no link to the
  contract's column (design §12, stage 3); all deferrable, none blocking.
- `storage::draw` passes a literal `8.0` where `ANCHOR_MARKER_SIZE` exists; `map.rs` redeclares
  it as `MARKER_SIZE = 8.0`. One constant.
- **No `CHANGELOG` entry for the four commits.** `fcc9914` rewrote the `WORKFLOW` paragraph
  (creations, folding, lanes, matrix, the dry-run estimate) and added no entry under a next
  version; the released `0.28.0` entry still says "Four kinds". The house rule is an entry per
  change with a `_Re-read:_` line, in the same commit.

## 8. What I skipped in the plan that the coordinator also skipped

- `tokio::join!` of the board read with `prepare` — saves ~0.5–0.7 s on Ascend; not blocking.
- The registry record of the map's frame and items — nothing can address the frame afterwards
  (no `relink`, no later note), and a second `map` cannot print the previous URL. Should be done
  before a user runs `map` twice, which is the first thing they will do.
- `--focus` and per-group frames — needed only above ~30 nodes; nothing on this machine is.
- `Lane::Horizontal` — correctly unnecessary once columns were kept.
- Corridors for skip edges — §1 and §3: this one is not optional; it is the complaint.

## 9. What to do before this ships, in order

1. **Failure semantics in `draw` (map and storage): run every batch to completion, name each
   failure, skip only the dependents, print the URL, fail at the end.** The current `??` aborts
   in-flight requests and hides what it created. One test with a faked failure.
2. **Skip edges through their corridors** (`routes`), or, if that waits, print "N arrows cross a
   column" so the picture's defect is on the terminal — the rule is to say so.
3. **Lane grid per gutter, gutter width from the arrow count in `prepare`, order by
   `(to_y, from_y)`**, with the tests in §3.
4. **The estimate: count the legend, count real calls in the client, print both; add `Creates`
   to the legend and its row to `padding_y`.**
5. **The outside is a panel, not boxes**, and the panel is drawn regardless of the flag.
6. **List what is not drawn**: unconnected deployables, vendored `new`s, an `Open` abstract.
7. **The `CHANGELOG` entry**, and the "Four kinds" correction.
8. Merge the legend/boxes/images/markers waves into one (§2) and add the plan's missing tests
   (§6). The registry record (§8) before the first second run.

Items 1–4 and 7 are rule violations or a wrong picture; 5 and 6 are the design not yet met in a
way the reader can see. Everything in §7 and the rest of §8 can follow.

---

## 10. Should the deploy's arrows be batched too? No — and here is the arithmetic

**What a deploy's connector phase is today.** `draw_one` builds one `PendingGroup` per call
site and spawns one task per group (`auto_deploy.rs:3794`); groups run concurrently under the
client's 48 permits, and sixteen frames run concurrently on top. Inside a group the calls are in
single file: token marker, edge marker, then per lane-routed callee `draw_lane_arrow`'s two
markers and three legs, each awaiting the last — a chain of `2 + 5k` round trips for `k`
lane-routed callees, or `3 + m` for `m` Miro-routed ones. Most groups have one callee, so most
chains are seven deep.

**What the measurement says.** `FLAMM.swap` after the split: 30 frames, ~5 700 items, 352.8 s,
zero retries, zero budget waits (`diagram-deploy-design.md` §22). That is 16.2 items a second,
or 969 writes a minute against Miro's 1 000. The run was within **3 %** of the credit ceiling
without ever touching the bucket. Whatever shape the calls take, 5 700 level-2 writes cannot
finish in under 5 700 / 16.7 = 341 s; the most any restructuring of the arrows can buy on that
deploy is about 12 s of 353. The "91 % network wait" in §21 is the budget, not the chains: the
chains only matter when there are too few of them in flight to keep 16.7 writes a second going,
which at latency *L* means fewer than `16.7 · L` concurrent chains — at *L* ≈ 0.5 s, fewer than
nine. A frame with fewer than nine call sites, drawn on its own, is the only case that is
latency-bound, and such a frame is ~60 calls and under five seconds either way. With sixteen
frames drawing at once the gap closes entirely.

So: **not worth touching for wall clock.** The honest number is "up to 3 % on a big deploy,
a second or two on a tiny one". There is one non-speed reason to touch the phase, and it is the
map's, not the deploy's: `lane_of` (`:3466`) is the lane rule the map needs (§11) and it is an
inline closure nobody can call or test. Lifting it into a pure `assign_lanes` in `layout.rs`,
used by both, changes no deploy behaviour and is worth doing; batching the deploy's arrows is
not.

**If it is ever done anyway, the shape that keeps the sharing** is two waves *inside each group
task*, not a global two-wave rewrite. Wave 1: the token marker, the edge marker and every lane
marker of every lane-routed callee (`plan_lane_arrow(..).points`), spawned together and joined —
the barrier. Wave 2: the stub (only when `!lane_routed`, `ArrowEnd::End`, as today), every
Miro-routed connector (cycles and laneless links, into the edge marker, `ArrowEnd::None`), and
every leg of every planned arrow (head on the leg the plan flags, none on the others), spawned
together. The task still returns `(owner, markers, connectors)`, so `callee_connectors` and the
surgical relink are untouched. What must stay sequential: the two waves (a leg needs its markers;
the stub needs both shared markers), and the group's decisions — `lane_routed`, the `strongest`
stroke, the `facing_anchor` for the Miro-routed fallback — made before wave 1, exactly where they
are made now. A global rewrite (all groups' markers, then all groups' legs) gains nothing over
this, because groups are already concurrent, and it would have to re-key every id back to its
group to preserve ownership.

**What would break, and what pins it today: nothing pins it.** The connector phase has no unit
test. `facing_anchor_test` covers the fallback's anchor sides and `color_test` covers the palette
(`the_overflow_never_uses_the_cycle_stroke` guarantees dotted is never confused with dashed), and
that is all. The four behaviours asked about — the stub drawn only when the group is not
lane-routed and carrying the head; a `back_edges` cycle taking the dashed, Miro-routed path into
the edge marker with no lane; a callee whose frame or image failed (today `draw_one` has already
returned `Err` from the image phase, so the group never runs; after the §2 fix it must be skipped
by name); and the lane order `(direction, start y, end y)` — are all decided inside the group
closure and the `lane_of` closure, with no seam to test at. The precondition for any change here,
batching or not, is to extract the two pure decisions — `assign_lanes` and a
`plan_group(&PendingGroup) -> (points, legs, stub)` — and pin those four behaviours on them.
Until that exists, the phase is correct by inspection only, and that is the strongest argument
for leaving its drawing loop alone.

## 11. The tangle: the fix for distant columns, concretely

What is on the board is explained by two things in `draw`, and the fix is one mechanism applied
twice. Geometry first, so the words below mean one thing: columns are the layers, left to right,
callee left of caller; every forward arrow travels **leftwards** from the caller's left edge to
the callee's right edge; a gutter `g` is the gap between column `g` and column `g+1`, bounded by
`R_g` (the widest right edge in column `g`) and `L_{g+1}` (the narrowest left edge in column
`g+1`); a lane is a vertical run at some `x` inside a gutter; a corridor is the horizontal gap
`insert_bend_points` reserved in a column, and `routes[i][j]` is its centre: a zero-size node, so
its `(x, y)` is **(the column's left edge, the corridor's centre y)**, with `gutter_y` of clear
space above and below it (`layout.rs:1448`, `:207–219`).

### 11.1 Lanes are per gutter, on a grid from the column edge — never per edge

The bunching comes from measuring each lane from *its own callee's* right edge and capping the
pitch: `lane_x = to_x + pitch·(order+1)` with `span` the whole distance to the caller. Replace
it with the deploy's rule (`auto_deploy.rs:3466–3521`), mirrored because the map's caller is on
the right:

- For gutter `g`, `margin = min(100, (L_{g+1} − R_g) / 4)`, `usable = L_{g+1} − R_g − 2·margin`,
  `n` = the number of arrows **whose chain passes through this gutter** (every forward edge from
  column `k` to column `c` passes through gutters `c, c+1, …, k−1`), `pitch = usable / (n − 1)`
  when `n > 1`, capped at `LANE_PITCH = 80`, and `lane_x(index) = R_g + margin + index·pitch`.
  The lanes then sit on one grid shared by every arrow in the gutter, 80 apart, starting 100
  from the callee column's edge — which is what "five stroke widths" meant: the *spacing*, not a
  ceiling on how far from the callee a lane may sit.
- "What when the gutter is wide" therefore does not arise: a gutter is `gutter_x` wide (550 by
  default) plus the slack of narrower boxes, and `n` lanes occupy `(n−1)·80` of it from the
  callee side. The horizontal legs do the travelling only across the slack, which is unavoidable
  and harmless.
- "What when it is narrower than `(n−1)·pitch`": do not squeeze — widen, and widen in `prepare`.
  `layout_graph` is pure, so call it twice: once with the default config to learn every node's
  layer, count the arrows per gutter from those layers (an edge `k → c` adds one to each of
  `c..k−1`), then call it again with
  `gutter_x = max(550, 2·100 + (n_max − 1)·80)`. The second layout is identical in order and
  differs only in `x`. The deploy's `min(.., LANE_PITCH)` then never binds, and the constant's
  comment becomes true. Pin it: a fixture with twelve arrows through one gutter yields twelve
  lanes exactly 80 apart and a gutter of `200 + 11·80`.
- Order within a gutter is the deploy's, mirrored: an arrow's `(start_y, end_y)` in gutter `g` is
  the y it **enters** the gutter at (the caller's y, or the corridor it came out of) and the y it
  **leaves** at (the callee's y, or the next corridor's). Arrows going down take the lanes nearest
  the callee column, ordered by start y; arrows going up take the outer ones, lowest start
  furthest out — the exact comparator at `:3496–3510`, which is what makes two arrows that need
  not cross not cross. With the map's travel direction reversed the comparator is unchanged:
  it reasons about y, and the lanes are counted from the callee column in both tools.

Lift the closure into `layout::assign_lanes(arrows: &[(key, start_y, end_y)], right_edge,
left_edge, pitch_cap) -> HashMap<key, f64>` and have the deploy call it where the closure was.
Tests: the twelve-lane grid above; the four-arrow fixture whose legs must not cross; and a
pinning test that the deploy's current `lane_of` output on a fixed fixture equals `assign_lanes`.

### 11.2 A skip edge is a chain through its corridors — `2s + 2` markers, `2s + 3` legs

An edge from column `k` to column `c` with `s = k − c − 1` skipped columns has
`routes[i] = [(x_{k−1}, y_{k−1}), …, (x_{c+1}, y_{c+1})]`, one corridor per skipped column, in
layer order. Only the **y** of each is used: the corridor is a horizontal gap the whole column
wide, and the chain crosses it at that height between the lanes on either side. The markers, in
drawing order from the caller:

```
M_0  = (lane_{k−1},  caller.y)          turn out of the caller's row into gutter k−1
M_1  = (lane_{k−1},  y_{k−1})           down (or up) the lane to the corridor of column k−1
M_2  = (lane_{k−2},  y_{k−1})           across column k−1 inside its corridor
M_3  = (lane_{k−2},  y_{k−2})           down the next lane to the next corridor
…
M_{2s}   = (lane_c,  y_{c+1})           across column c+1 in its corridor, into gutter c
M_{2s+1} = (lane_c,  callee.y)          down gutter c's lane to the callee's row
```

where `lane_g` is this arrow's lane in gutter `g` from §11.1. That is `2(s+1) = 2s + 2`
markers. The legs, with their anchors:

1. **caller → M_0**, horizontal, leftwards: from the caller box's left side
   `RelativeAnchor(0.0, 0.5)` to `M_0`'s right side `(1.0, 0.5)`. No head.
2. **M_0 → M_1**, vertical, in the lane: `(0.5, 1.0)` to `(0.5, 0.0)` when going down,
   swapped when going up — `plan_lane_arrow`'s own rule. No head.
3. **M_1 → M_2**, horizontal, through the corridor: left side of `M_1` to right side of `M_2`.
   No head.
4. **M_2 → M_3**, vertical — and so on, alternating, `2s` legs for the `s` corridors.
5. **M_{2s+1} → callee**, horizontal, leftwards into the callee box's right side
   `(1.0, 0.5)`, **this leg carries the head** (`ArrowEnd::End`), because it is the one that
   touches the callee, and an arrow head anywhere else points at a marker nobody can see
   (`client.rs`, the reason `ArrowEnd::None` exists).

Horizontal legs: `1 + s + 1`; vertical legs: `s + 1`; total `2s + 3`. For `s = 0` the chain is
`M_0, M_1` and three legs — `plan_lane_arrow` exactly, which is how to build it: generalise it to
`plan_lane_chain(from: LaneEnd, to: LaneEnd, lanes: &[(lane_x, exit_y)])`, where the slice holds
one `(lane_x, y)` per gutter from the caller side — `exit_y` being the corridor's y for every
gutter but the last, and the callee's y for the last — and `plan_lane_arrow(from, to, x)` becomes
`plan_lane_chain(from, to, &[(x, to.y)])`. Pin the equality with a test, so `storage` and the
deploy cannot drift from it. The estimate in `prepare` then counts `2s + 2` and `2s + 3` per edge
from the layers it already has, instead of `4` and `3`.

Two details the picture depends on. The corridor leg runs **between the lanes**, from `lane_{g}`
to `lane_{g−1}`, so it starts and ends in gutters and crosses the column only inside the reserved
gap — if it were drawn to the corridor's own `x` (the column's left edge) it would need two more
markers per column and gain nothing. And every arrow of a chain claims a lane in **every** gutter
it crosses, which is why §11.1 counts chains, not edges, when sizing a gutter.

Back edges stay as they are: one Miro-routed connector, dashed, from the caller's right side to
the callee's left side (the caller is on the left for a back edge). They are the exception the
legend names, and `insert_bend_points` reserves them nothing (`layout.rs:1436`).

### 11.3 Is the box order part of the problem? Not the direction — but two things are

`order_layers` and `count_crossings` are direction-agnostic once `from_line_fraction` is a
constant: the barycentre of a node is the mean position of its neighbours in the adjacent layer
whichever way the edge points, and the crossing count keys on `slot + 0.5` for every edge. So
ordering the reversed graph yields the same order as ordering the real one. Feeding the engine the
real direction would change nothing and should not be done.

What does matter, and is already right: the bend points are inserted **before** the ordering
(`layout.rs:150`), so each corridor is a slot the barycentre sweep places near the boxes its
arrow connects, and the columns move apart to make room for it. Once the chain of §11.2 actually
uses the corridors, that ordering is doing its job; today it reserves gaps nobody travels through.

The two things that are a problem: first, every arrow leaving one caller leaves at the same
point, `(caller.left, caller.y)`, and runs along the same y until the lanes split them — five
arrows from `StakingFeeConverter` are one line for the first few hundred units. Spread the
exit anchors along the caller's left edge by lane order (`y_fraction = 0.5 ± i·Δ`, with
`Δ = 40 / NODE_HEIGHT` so the stubs sit 40 units apart at a 320-unit box), and the landing
anchors along the callee's right edge the same way; the deploy cannot do this because its
anchor is a token on a specific line, and the map can because a box has no lines. Second,
column 0 is ordered by the sweep, not by in-degree from the centre out (design §7); the sinks
with the most arrows should sit mid-column so their fan-in splits above and below rather than all
coming from one side. That is a final pass over layer 0 after `order_layers`, and it is what
makes "everybody writes to this one" visible as geometry.

### 11.4 In the order to do it

1. `assign_lanes` lifted from the deploy, with its three tests; the deploy unchanged in output.
2. `prepare` counts arrows per gutter from a first layout and re-lays out with the widened
   `gutter_x`; `ApiCalls` counts `2s + 2` / `2s + 3`.
3. `plan_lane_chain` with `plan_lane_arrow` as its one-lane case, pinned equal by test.
4. `draw` builds one chain per forward edge from `routes[i]` and the per-gutter lanes, spreads
   the exit and landing anchors, and keeps the back edges as they are.
5. The centre-out order of column 0.

Each step is checkable on the Ascend dry run before the board is touched: after step 2 the
estimate changes (three arrows skip a column there, so `+6` markers and `+6` connectors), and
after step 4 the one picture to verify is `AscendCtoRegistry → StakingFeeTreasury` running down
its lane, across each corridor, and into the callee with nothing under any of its legs.