# The contract map — development plan
`docs/contract-map-design.md` says what the map should be. This is the plan for building it: the
planning phase that measures and renders everything before the first API call, where each of the
five agreed stages lives inside that split, and how the drawing half is parallelised against Miro
with every number carrying its reason. It is written against `develop` after `3416fad`
(`relations()` and its four fixes are in), `storage.rs`'s `prepare`/`draw`, `auto_deploy.rs`'s
`plan_cluster`/`draw_cluster` (§21–§22 of `diagram-deploy-design.md`) and `client.rs` as of
0.27.0. Nothing here is code, and nothing here touches a board.
---
## 1. Why a planning phase, and what it must guarantee
`map::run` today interleaves deciding and drawing in the way `deploy_one` used to: it computes the
relations, lays them out, creates the frame, and only then draws legend, boxes and connectors,
measuring nothing it has not already drawn. The design adds three rendered images — the legend,
the outside panel and the matrix — whose pixel widths decide how wide the frame is, and the
frame's width decides where on the board it opens (`open_left` needs it). So the order has to be:
render, measure, lay out, size the frame, read the board, place, and only then make the first API
call. That is exactly the split `storage.rs` has (`PreparedStorage` holds two rendered PNGs and the
frame's measured width before `draw` is called) and `prepare_effects` has, and the one the deploy
paid 865 s → 352 s to get.
Two guarantees follow, and they are what the split is for. **Every failure that can be known
locally happens before anything exists on the board**: an empty relation set, a scan that
predates the flag, a PNG over Miro's 6 MB, a frame wider than the layout can place. And
**`--dry-run` describes the real thing**, not a sketch: it prints the final frame size, the counts
of what would be drawn, and the number of API calls, because all of those are properties of the
prepared value and not of the drawing.
What the split cannot guarantee is atomicity on the board — Miro has no transaction. Once the frame
exists, a failed box or connector leaves a partial frame. The rule for that is the repository's
canonical one: one failed item does not stop the batch; the rest completes; the failure is reported
by name with its reason at the end, with the frame's URL, and nothing is deleted. §6 says how.
## 2. The prepared value
`PreparedMap`, built by `map::prepare(metadata, options) -> Result<PreparedMap>` — local,
synchronous, no client, no board — and consumed by `map::draw(prepared, client, left, top)`.
Everything the drawing needs and nothing it has to decide:
- **The model.** `nodes: Vec<MapNode>` — one per deployable contract (key `(file, name)`, label,
group, `called_by`, `calls`, `reaches_outside`, the `label_id` of its board column when
`miro.auto.columns[]` has one, so the box can carry a link), plus `unconnected: Vec<MapNode>`
for the strip on the right. `edges: Vec<MapEdge>` — `(from, to, relation, back: bool)`, one per
pair, with `relation` one of `Calls | Creates | WiresAtConstruction | Unresolved`. The outside is
not an edge: `outside: Vec<(String, Vec<String>)>` — external system, who reaches it, ERC-20
family merged.
- **The layout.** `layout: GraphLayout` from `layout_levels` (§3, stage 3): every node's centre in
frame-local coordinates, `routes` per edge (the corridor points of an edge that skips rows),
`back_edges`, and the bounding box. Plus `lanes: Vec<Lane>` (§3, stage 4): for every forward
edge, the lane's y in its gutter and the list of legs as `(from point or item, to point or
item)` pairs — decided here so the draw only transcribes.
- **The three renders.** `legend`, `outside_panel`, `matrix`: each a `Rendered { png_path, width,
height }` (the type `storage.rs` already has), at `REFERENCE_FONT` scaled by
`BOARD_UNITS_PER_PIXEL`, written to `BatFolder::Figures` under a name carrying the project and
a run id, so two maps of one project at once do not delete each other's files (the deploy's §15
lesson). `matrix` is `None` under four nodes; `outside_panel` is `None` with `--no-outside` or
when nothing leaves.
- **The geometry.** `frame_width`, `frame_height` (the layout's box, the three images beside and
below it, and a margin from `breathing_room`), and every item's final frame-local position —
boxes, markers, images — in one `Vec<Placed>` so that `draw` is a loop, not a decision.
- **The counts, for the report and the estimate.** `api_calls: ApiCalls { frame, boxes, markers,
images, connectors }`, computed from the lanes (§5 has the arithmetic), and `crossings` from
`count_crossings` on the final order.
- `discard()` removes the PNGs, as `PreparedStorage::discard` does — called by `--dry-run` and on
any failure before the frame is created.
The report `--dry-run` prints, in the §11 reading order of the design: the levels top to bottom
with each row's members and their `called by · calls`; the red pairs; the open bindings with their
`bat-cli resolve` lines; the outside panel as text; the matrix as text; the unconnected; the
folded libraries ("FLAMMFlowLib → part of FLAMM") and the unused ones; the untyped receivers and
the undeclared interfaces (both already printed today); then one block the drawing half would
otherwise be the first to know:
```
frame: 14 200 × 9 600 board units, 5 rows, 13 boxes, 18 arrows (3 skip a row, 1 red), 3 images
API calls: 1 frame + 13 boxes + 42 markers + 3 images + 63 connectors = 122
at Miro's 1 000 writes/minute that is 7.3 s if nothing else limits it
placement: needs the board; not read in a dry run
```
The last line is deliberate: a dry run is local, the board read is an API call, and saying where
the frame *would* go requires one. The dry run says so instead of guessing.
## 3. Where the five stages sit
Every stage is a change to `prepare` or to the pure modules it calls. `draw` changes only in
stage 4 (lanes) and stage 5 (two more images), and in neither case does it decide anything.
**Stage 1 — `new X()` creations.** No scan change is needed: `call_resolver.rs:255–283` already
turns `new X(...)` into a callee named `X.constructor`, and the Everlong metadata carries them
(`RouterDeployLib.deployRouter → MMRouter.constructor`, `TrancheController.constructor →
TrancheToken.constructor`, `MorphoAccountDeployer.deploy → MorphoBlueAccount.constructor`). The
map reads `function_dependencies.callees`, takes every `X.constructor` string, resolves `X`
through `contract_in_scope(X, caller.file)` — never by name alone, or the three vendored
`ProxyAdmin`s decide — and records `Relation::Creates` when the target is a deployable contract in
scope. `Creates` sits between `Calls` and `WiresAtConstruction` in the enum's order, so a pair
that is both created and called keeps the call (it does call the constructor); a pair only created
keeps `Creates`. On the board it is a solid arrow with the caption `creates` — the one caption
worth its cost, because factories are few and "this contract exists because that one made it" is
a fact nothing else on the map says. A `new` of a vendored or unknown type is not a relation and
is listed under the untyped receivers, since a factory deploying somebody else's code is worth a
line. Tests: a constructor callee resolves to a `Creates` edge; created-and-called keeps the
call; `new` of a vendored type is listed, not drawn; two same-named targets resolve through the
caller's file.
**Stage 2 — folding libraries into their hosts, in the map only.** `hosts(metadata) ->
BTreeMap<(file, name), BTreeSet<(file, name)>>`: for every in-scope library, the deployable
contracts whose code it is, from the `Lib.fn` strings in `function_dependencies.callees`
resolved with `contract_in_scope`, iterated to a fixpoint so a library reached only through
another library (`FLAMMStore` via `FLAMMFlowLib`) reaches `FLAMM`. Abstract contracts fold the
same way through `base_contracts`. `relations()` then runs its collector once per deployable node
**and once more per library or base that node hosts**, attributing what the library does to the
host; a library on the ignore list still folds (ignoring hides a box, never a relation). This
touches nothing in `build_graph`, `resolve_call` or `effects`: the entry-point graph keeps drawing
every library function as its own screenshot, which is what the auditor reads there. The two
small helpers the map needs — resolve a `Lib.fn` or `X.constructor` string to a contract in the
caller's scope — live in `map.rs`, not in `auto_deploy.rs`, precisely so nobody is tempted to
make the deploy fold. Tests: a library used by one host attributes its calls to the host; a
library used only by a library folds transitively; a library used by every host produces no edge;
an unused library is listed; the deploy's `build_graph` output on a fixture with a library is
byte-identical before and after (the test that proves "map only").
**Stage 3 — levels.** `layout::layout_levels(nodes, edges, config) -> GraphLayout`, rootless,
top-down, longest path to a sink, as the design's §7 specifies: `split_back_edges` generalised to
an optional root (it already seeds every node), a new `assign_levels`, then the existing
`insert_bend_points`, `order_layers` and `count_crossings` unchanged, rows centred, bottom row
ordered by in-degree from the centre out. `prepare` calls it with the connected nodes only; the
unconnected strip is placed by `prepare` to the right of the bounding box. Tests as listed in the
design's stage 2: chain, diamond, cycle, source-to-sink, exact crossing count.
**Stage 4 — lane-routed arrows, reusing `draw_lane_arrow`.** `draw_lane_arrow`
(`auto_deploy.rs:1547`) draws one arrow as a vertical lane at `lane_x` with horizontal legs,
because the deploy's layers are columns. The map's layers are rows, so its lane is **horizontal**,
at `lane_y` in the gutter below the caller's row, with vertical legs. Rather than a second copy of
the geometry (the design doc's §18 records what a second copy of `line_anchor` cost), generalise
the function by axis: `Lane::Vertical { x } | Lane::Horizontal { y }`, with `facing` choosing
top/bottom anchors for a horizontal lane as it chooses left/right for a vertical one; the deploy
passes `Vertical` and its behaviour is unchanged, pinned by a test that the generated anchors for
a vertical lane are what they are today. Lane assignment mirrors the deploy's rule transposed:
lanes in one gutter are ordered by `(from x, to x)` so non-inverted pairs never cross, at a pitch
of five stroke widths — the deploy uses `LANE_PITCH = 40` at stroke 8; the map's stroke is 16,
so 80 — and the gutter widens to `2·margin + (n−1)·pitch` when it must. An edge that skips rows is
two lane arrows joined at the corridor `insert_bend_points` reserved: down to the caller's gutter
lane, across to the corridor x, down through the skipped rows, across the callee's gutter lane,
down into the callee. In calls, an arrow that skips `s` rows is `2 + 2s` markers and `3 + 2s`
connectors; `prepare` computes exactly that per edge and that is where `api_calls` comes from.
Back edges stay one red Miro-routed connector from the caller's top to the callee's bottom, as the
design says: the one arrow allowed to look different is the one that is a finding.
One more change to `draw_lane_arrow` is what makes §5 possible: today it creates its two markers
and then its three connectors **sequentially inside one call** — five round trips per arrow, each
waiting for the last. Split it into `plan_lane_arrow` (pure: the marker points and the three legs
as `(end, anchor, end, anchor, style)`) and leave the drawing of legs to the caller, so the map can
create every marker of every arrow in one batch with the boxes and every connector in a second
batch. The deploy can keep calling the sequential wrapper until it wants the same gain.
**Stage 5 — the matrix and the outside panel.** Two more `silicon::create_figure` renders in
`prepare`, built from the same lines `--dry-run` prints, so the terminal and the board show one
thing (the `effects_report` principle). The matrix is a `.txt` render so nothing is highlighted;
column headers are numbers when names would not fit, rows carry `12 FLAMM`; every line parses
(the storage table's lesson about an unbalanced `(`). Their widths enter `frame_width` here, which
is the reason this plan exists. Tests: the matrix of a three-edge fixture has the expected marks
above and below the diagonal and the right column sums; the ERC-20 family merges to one line; the
panel is `None` when nothing leaves; a frame's width is the layout plus the wider of the two
images plus margins.
## 4. The drawing half: what depends on what
Every Miro item the map creates is a child of the frame, so the frame id is the first thing
everything needs. Every connector needs both of its endpoints' ids. Nothing else has a
dependency. That gives a three-level graph:
```
┌─ create_frame ─┐ (1 call, sequential)
│ │
┌───────────┼────────────────┼─────────────┐
▼ ▼ ▼ ▼
images (3) boxes (N) markers (M) [registry: miro.map ← frame_id]
create_image create_map_node create_anchor_marker (all concurrent)
└───────────┴────────────────┘
▼
connectors (C), each needing its two endpoint ids (all concurrent)
▼
registry: miro.map ← item ids; print the URL
```
Before the frame: `list_frames` (level-1 reads, paginated, one call per 50 frames) for
`open_left`. It is an API call, so it is after `prepare` succeeds and before `create_frame`; it
does not depend on `prepare`, so it can run concurrently with it (`tokio::join!`) and the wall
clock is `max(prepare, read)` rather than the sum — worth it because on a board of a few hundred
frames the read is several pages, and today's timing print shows it at 0.5–0.7 s on Ascend.
**What must be sequential, and why.** The frame, because every other item names it as parent
(`create_image_in_frame`, `create_label(.., parent)`, `create_anchor_marker(frame_id, ..)` all
take it). Each connector after its own two endpoints, because the API rejects an endpoint id that
does not exist. The registry write after the frame id exists (so a crash mid-draw still leaves the
id recorded) and again after the ids are known. Nothing else: **markers can go up with the boxes**
— a marker is a shape whose only reference is the frame and whose position `prepare` fixed — and
**the three images can upload while the boxes are being created**, for the same reason. The one
thing that would break this is deciding a position during the draw, which is the §21 trap ("two
walks that can disagree") and is why `prepare` owns every coordinate.
**Within the middle level, the issue order is images, boxes, markers.** The semaphore is fair, so
spawn order is issue order, and the images are the longest calls (a multipart upload of a PNG,
hundreds of kilobytes) — starting them first means their latency overlaps everything else instead
of trailing it. At Ascend's size (52 calls against 48 permits) it hardly matters; at eighty nodes
(~480 calls in this level) it is the difference between the images finishing with the batch and
after it.
**Connectors wait for the whole middle level, not for their own two endpoints.** A per-arrow
readiness graph would let the first connectors start one latency earlier; it costs a map of
futures and the possibility of drawing a connector whose far endpoint failed. The barrier loses at
most one straggler's latency — under a second — and keeps the draw a flat loop over the plan.
## 5. The concurrency numbers, with the arithmetic
The facts, from `client.rs` and the deploy's measurements:
- Miro's budget is 100 000 credits per minute per user and application
(`CREDITS_PER_MINUTE`); a level-2 write — frame, image, shape, connector — costs 100
(`LEVEL_2_CREDITS`). So **1 000 writes a minute, 16.7 a second**, is the hard ceiling, and the
client enforces it locally with a per-minute token bucket (`reserve`), which means a 429 should
never be the first sign of it: the `credit budget exhausted` warning is.
- `MAX_CONCURRENT_REQUESTS = 48` permits, shared by one client across the whole run.
- Retries: five attempts, `Retry-After` honoured on 429, exponential backoff from 500 ms on 5xx
and from 400 ms on transport errors (`execute`).
- Measured on the deploy (§22): 30 frames, ~5 700 items, 352 s → 16.2 items a second, at 24
permits, with zero budget waits and zero retries. That is the budget's ceiling, reached without
touching the bucket, so **per-call latency is at most 24 / 16.7 ≈ 1.4 s** and in practice
lower. Nobody has measured it directly, which is §7's first job.
The map's sizes, from the design's §4 (Ascend as scanned, Everlong after folding, and the
eighty-node case the design plans for), with the per-arrow cost from stage 4 assuming one arrow
in six skips a row:
| Ascend, 13 nodes / 18 arrows | 1 | 13 | 42 | 3 | 63 | 122 | 7.3 s |
| Everlong, ~18 nodes / ~30 arrows | 1 | 18 | 70 | 3 | 105 | 197 | 11.8 s |
| 80 nodes / ~200 arrows | 1 | 80 | 467 | 3 | 700 | 1 251 | 75 s |
So the map is **budget-bound**, not latency-bound, at every size that matters, provided enough
calls are in flight to keep the bucket busy. The permits needed to saturate 16.7 calls a second at
latency *L* are 16.7 · *L*: at 0.5 s, 9; at 1 s, 17; at the 1.4 s upper bound, 24. The existing
48 covers every case with room, and raising it buys nothing — the bucket is the wall. The wall
clock the map can honestly aim at is therefore: `prepare` (local, a few hundred milliseconds per
render) overlapped with the board read, plus one latency for the frame, plus the middle level at
the budget rate, plus the connectors at the budget rate, plus one latency of barrier slack: for
Ascend about **9–10 s**; for the eighty-node case about **80 s**, of which 75 is Miro's quota.
The limits I would set, each with its reason:
- **No new concurrency constant in `map.rs`.** The deploy needs `CONCURRENT_FRAMES = 16` because
a frame serialises inside itself (markers, then connectors, in chains) and the number keeps the
permits busy across those gaps. The map has no such gap once stage 4 flattens the arrows: its
two batches are flat sets of independent calls, so the client's 48 permits and the bucket are
the only limits worth having, and a second number would be one more thing to explain. Spawn
every task of a level into one `JoinSet` and let the semaphore meter it — tokio holds a thousand
idle tasks for nothing.
- **Keep `MAX_CONCURRENT_REQUESTS` at 48.** 24 already reached the budget without a wait; 48 is
the margin for a slower day. If the per-call latency `map` measures (§7) ever exceeds 2.9 s,
48 permits can no longer fill 16.7 a second (48 / 2.9 = 16.5) and the number should rise with
that measurement beside it; until then it is not the limit.
- **The budget window is per minute, not sliding.** A 1 251-call map spends the first 1 000
credits' worth of calls in the first ~60 s and then the bucket sleeps to the window's end (plus
50 ms) — `reserve` does exactly that. So the 80-node estimate is really 60 s + the rest in the
next window, which is what "75 s" already assumes; the warning will appear once, and the plan
should expect it rather than treat it as a fault.
- **Failures.** A failed box or marker is recorded by name; every connector that needed it is
skipped and listed; the rest of the batch completes; the run ends non-zero with the frame's URL,
the list, and the reason per item, nothing deleted (§8 of the design doc: no bulk delete, no
cascade, and the deploy's "one frame failing must not cost the thirty that worked" at
`auto_deploy.rs:1195`). A failed frame creation is the only failure that leaves nothing, and it
is reported with the body Miro returned — the design doc's §19 lesson that an overlap answers
`500`, which `open_left` is there to prevent.
## 6. What `draw` looks like, as a loop
```
let frame_id = client.create_frame(..).await?; // level 0
record(miro.map ← frame_id) // so a crash still knows the frame
let mut level = JoinSet::new();
for image in &prepared.images { level.spawn(create_image_in_frame(..)) } // issue order:
for node in &prepared.nodes { level.spawn(create_map_node(..)) } // images, boxes,
for point in &prepared.markers { level.spawn(create_anchor_marker(..)) } // markers
let ids = collect(level) — failures by name, no early return
let mut arrows = JoinSet::new();
for leg in prepared.legs_with_known_endpoints(&ids) { arrows.spawn(create_connector(..)) }
collect(arrows) — failures by name
record(miro.map ← ids); print url; print failures; exit non-zero if any
```
`create_map_node` is the one client addition (design §12, stage 3); everything else is a call
that exists. The back edges are ordinary connectors in the second batch, with their own style.
## 7. How to measure it afterwards
`map` already prints a line per phase with its elapsed seconds. Split and extend them so each
number can be compared with a ceiling:
1. `reading the metadata`
2. `working out the relations` — the model, folding, creations
3. `levelling and routing` — `layout_levels` plus lane assignment, with the crossing count
4. `rendering 3 image(s)` — the silicon renders, with their pixel sizes
5. `reading the board (N frames, P pages)` — overlapped with 2–4; print both its own time and
the overlap saved
6. `creating the frame`
7. `furniture: N boxes + M markers + 3 images in T s = R calls/s` — the first number to read
against 16.7: at or near it the budget held the line, well under it the latency did
8. `arrows: C connectors in T s = R calls/s`
9. `recording`
10. `in total`, and beneath it `retries: n, budget waits: m`.
The last line needs two counters the client does not expose today — `execute` logs a warning on
each retry and `reserve` on each wait, and nothing counts them. Add an `AtomicU32` pair on
`MiroClient` and a `stats()` accessor; it is the cheapest way to turn "did Miro push back" from a
`-v` grep into a number on every run. Phases 7 and 8 are the ones the design was waiting for: if
either reads under ~14 calls a second with zero waits, latency is the limit and the per-call figure
(T / calls, as an upper bound) is the number to raise the permits against; if either reads ~16 with
waits, the budget is, and nothing in this tool can move it.
Verify on the same three dry runs and one real frame the design doc's §14 names, in that order;
the dry runs now also assert the `API calls` block, and the real frame compares the actual call
count printed in phases 7–8 with the dry run's prediction — they must be equal, or `prepare` and
`draw` have diverged, which is the one bug this whole plan exists to prevent.
## 8. Order of work
1. Stage 2's split of `relations()` into model + collector, with `PreparedMap` holding the model
and the counts, and `--dry-run` printing the `API calls` block from today's auto-routed arrows
(`1 + N + 0 + 1 + E`). This is the skeleton; everything else fills a field.
2. Stage 1 (creations) — small, in the collector.
3. Stage 3 (levels) in `layout.rs`, then `prepare` places the unconnected strip.
4. Stage 5 (matrix and panel renders) — they set `frame_width`, which stage 4's gutters need.
5. Stage 4 (lanes): the axis generalisation and the `plan_lane_arrow` split in `auto_deploy.rs`
with the pinning test, then the map's lane assignment and the flattened two-batch draw of §6.
6. The counters and the phase lines of §7, and the real-frame measurement.
Each step ships with its tests and is verified with `cargo test map_test` (and `layout_test` for
step 3, `lane_test` for step 5) — never the full suite — plus a `--dry-run` on Ascend and
Everlong; the board is touched once, at step 6.