bat-cli 0.28.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
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# Contract groups on the board — a review before any code

This is a review of what it would take to give the board a notion it does not have today: a
GROUP per contract. The feature as asked for has four parts. Every entry point of a contract
stacks vertically in that contract's own column, and different contracts get different columns.
The first deploy of a contract also draws that contract's `bat-cli storage` frame, to the LEFT of
that first entry point's frame. Above each column sits a note carrying the contract's name. And
`effects` is drawn automatically with every entry point, to the RIGHT of and close to that entry
point's own frame.

Nothing here changes code or touches a board. It reads `auto_deploy.rs`, `layout.rs`,
`storage.rs`, `overview.rs`, `bat_metadata.rs` and `client.rs` as they are on `develop` at
0.26.49, and it is organised the way the question was asked: the edge cases first, then a staged
plan, then the questions whose answers change the code. Line numbers are from that revision.

A note on vocabulary before anything else. The code's unit is the **deployment**: one
`cluster_root`, the entry point, owning a list of frames in reading order (§17 and §20 of
`diagram-deploy-design.md`). A **column** is a new, larger unit — a contract, owning several
deployments plus a label and a storage frame — and nothing in the registry knows it yet. Most of
the edge cases below come from that mismatch: the deployment is replaced wholesale and placed
fresh every time, while a column has to persist and accumulate.

---

## A. The edge cases

### A.1 Where things are placed today, which is what every case below bends

Three placement rules exist and they do not agree with each other.

The deployment uses a cursor. `run` forgets the cached region before every deploy
(`auto_deploy.rs:819-823`), `resolve_allocator` asks the board for its frames once and puts the
origin under the lowest one (`:1454-1474`, `below_everything` at `:1550-1558`, with a
`REGION_MARGIN` of 5 000 at `:47`), and from that origin `place_in_outline` hands out slots with
no further look at the board (`layout.rs:684-709`). Rows are exclusive horizontal bands, which is
the only reason two frames of one deployment cannot overlap. The cursor is persisted to
`miro.auto.region` after each entry point (`auto_deploy.rs:1096-1101`) and thrown away at the
start of the next run, so persisting it is dead weight today.

`effects --deploy` and `storage` use the board. Both call `list_frames` and `beside_cluster`
(`auto_deploy.rs:1486-1548`, used at `:490` and `storage.rs:487-496`), which finds the live box of
the deployment's frames by id, tries a spot to its right, and steps DOWN by the frame's height
until a candidate clears every frame on the board, forty times at most; failing that they fall
back to below everything. This is why the effects frame today lands "a screen apart": to the right
of a cluster is usually the next thing that was deployed, so the first clear step is a long way
down.

Type frames use the registry. `struct_frame::free_rect` (`struct_frame.rs:442-478`) spirals out
from the host testing candidates against the rectangles the REGISTRY recorded, not the board.
This is the one placement that trusts stored positions, and it is wrong the moment the auditor
drags anything, which is the first thing §18 warns about.

The two facts the feature cannot escape: Miro refuses a frame that overlaps another with a `500`
(§18, §19), and `list_frames` returns only items of `type=frame` (`client.rs:336-346`), so a
sticky note, a shape or a text item is invisible to every scan the code does.

### A.2 A column whose next cluster is wider or taller than its first

Taller is free: a column grows downward, and nothing of another contract lives in its x-band, so a
tall cluster only moves the column's cursor further down. Wider is the real case. If the second
column was opened at `first_column.right + gap` because the first cluster was 20 000 wide, and the
next cluster of the first contract is 45 000 wide, it reaches into the second column and Miro
refuses it.

A cluster's width is not bounded by anything the planner controls. The outline wraps a row at
`row_max_width` = 60 000 (`layout.rs:640`, checked at `:692`), but the check is "does the NEXT
frame still fit": a row at 59 000 happily takes a 25 000-wide frame, so a cluster can be ~85 000
wide, and a single wall frame can be anything — §15 records a 250-screenshot frame that shipped.
`FramePlan.frame_x/frame_y` are decided inside `plan_one` (`auto_deploy.rs:1902-1907`), before
anything is drawn, so the cluster's bounding box IS known before the first API call; it is just
not used for anything.

What follows is that a column cannot promise a fixed width, and the only safe rule is the one
`beside_cluster` already uses: test the candidate rectangle against every live frame and move it
until it clears. Because every column grows down and nothing is ever placed above an existing
frame, "move down until clear" always terminates — at worst below everything — and a cluster that
outgrows its column lands further down the same column than its cursor, past whatever neighbour it
would have hit. The column reads as having a gap; it does not read as broken. The alternative, a
continuation column to the right for the same contract, breaks "one column per contract", and
failing the deploy breaks the auditor's rule that a diagram is never refused for size. Reserving a
generous pitch per column (the outline's own 60 000 plus a gutter) makes the overflow rare; the
push-down makes it safe. Both are needed, and the push-down is the part to unit-test.

### A.3 An entry point deployed twice

Deploying again is already "replace": the old cluster's ids are collected up front
(`auto_deploy.rs:879-889`), the question is asked unless `--yes` (`:905-915`), the new cluster is
drawn, and the old records are dropped by id at the end (`:1052-1056`). The old FRAMES stay on the
board, deliberately (§8: no bulk delete, no cascade). Today the new cluster goes below everything.

In a column the new cluster appends at the bottom of the contract's column, and the old one stays
above it. That is acceptable — it is what happens today, just in the right column — but three
things attached to the deployment now have to decide whether they are replaced too.

The storage frame is a property of the COLUMN, not the deployment, so a second deploy must not
redraw it. "First deploy" therefore cannot mean "this entry point has no record"; it has to mean
"this column has no live storage frame", checked on the board (storage already looks up its own
previous frame by title, `storage.rs:507-516`). The effects frame is a property of the DEPLOYMENT:
it describes one entry point's tree, so a redeploy should replace it exactly as it replaces the
frames. `effects --deploy` never replaces anything today — unlike storage it does not look for a
previous `effects: X` frame — so two deploys leave two reports unless the effects frame joins the
deployment's records and is dropped with them. The label is a property of the column and is
untouched.

A smaller consequence: the `previous_extras` restore phase (`:850-877`, `:1063-1093`) puts the
auditor's `screenshot` additions back on the frame of the same name in the new cluster. Nothing
about columns changes it, but it runs `screenshot::run` one at a time and each call reads the
registry; if the effects frame becomes a record with a `screenshots` list, the restore loop must
skip it (it already skips `type_frame`, `:871`).

### A.4 Frames the auditor has dragged

The registry stores where a frame was born (`AutoDeployedFrame.x/y`, written once at
`auto_deploy.rs:2466-2500`), and the code is honest about it: `beside_cluster` reads the live box
from the board precisely because "the recorded position is only where a frame was born"
(`:1479-1482`). So a column must not persist its geometry either. If `miro.auto.columns[]` stored
`x`, `cursor_y` and `width`, the first dragged frame would make the stored column lie, and the
next cluster would be placed into whatever the auditor dragged there — or into empty space the
auditor deliberately cleared.

The rule that already works: persist IDENTITY, derive GEOMETRY. A column record holds which
contract it is, the label's item id and the storage frame's id; its left edge and its bottom are
computed every run from the live frames that belong to it — every registry frame whose
deployment is of that contract, found in the `list_frames` result the run already pays for. A
column whose frames the auditor spread apart simply spans them. A column whose frames were all
dragged into one pile still has a left and a bottom. Nothing about the auditor's arrangement is
undone, which is the trade §17 and `relink` already chose ("deploying again would fix the record
and lose the arrangement, which is the wrong trade", `:7156-7161`).

One thing the board cannot answer: where the LABEL is. It is not a frame, so `list_frames` does
not return it, and dragging it moves nothing the column is anchored to. The label should be placed
relative to the column's derived top-left each run and moved there if it drifted
(`update_item_position` exists, `client.rs:633`) — or simply left where the auditor put it, which
is the cheaper rule and the one I would start with.

### A.5 A frame deleted by hand

For deployment frames the code has an answer: `ensure_frame_record` asks the board with
`item_status` (`auto_deploy.rs:7457-7560`, `client.rs:927-946`), forgets the record only on a
definite 404, and keeps it when the board could not be asked (§19: an error is not evidence of
absence). The column needs the same discipline for its two owned items. A storage frame deleted by
hand must be drawn again on the next deploy of that contract — which falls out naturally if "first
deploy" is defined by board presence (A.3). A deleted label is recreated the same way. A column
whose every frame is gone has no derived geometry; it is then a new column, placed where new
columns go, and its stale record is replaced rather than consulted.

The subtle case is partial deletion: the auditor deletes the FIRST cluster of a column, the one the
storage frame sat beside. The column's derived top moves down to the next cluster; the storage
frame is now above and left of everything else in the column. That is cosmetic and should be left
alone — moving frames the auditor did not ask to move is the one thing the board rules forbid.

### A.6 Two contracts with the same name

CLAUDE.md is emphatic and the deploy path already obeys: a target is `(contract, function, file)`
(`auto_deploy.rs:1126-1131`), `plan_one` prints `from <file>` when the name is shared
(`:1612-1616`), and lookups go through `contract_in_scope` (`bat_metadata.rs:509`). So the column
key must be `(name, file_path)`, never the name, or `BeaconProxy` vendored three times becomes one
column with three contracts' entry points in it. The label then needs the same rule the deploy
output uses: the bare name when it is unique on the board, `path/To.sol:Name` when it is not.

Two places do NOT obey today and will be in the way. `storage::pick_contract`
(`storage.rs:108-131`) matches by name among non-vendored contracts and refuses when several
match, and `forge_layout` runs `forge inspect <Name>` by bare name (`:139`), which `forge` itself
resolves ambiguously. So for a column keyed by file, the storage half has to accept a file and
pass `path:Name` to forge (which forge accepts). And `beside_cluster` matches frames by
`entry_point == title` as well as by `cluster_root` (`auto_deploy.rs:1500`): with two in-scope
contracts of one name, `title` is the same string and it would pull the other contract's frames
into "this cluster". Deriving column geometry from the registry's frames needs the frame record to
carry the root FILE, which it does not today (`cluster_root` is `Contract.function`,
`bat_metadata.rs:403`). That is one new field on `AutoDeployedFrame`, defaulted so old files load.

### A.7 A storage frame that cannot be built

`storage::run` fails hard in three places: `forge` is not installed (`storage.rs:139-146`), the
project does not compile (`:147-152`), forge printed something that is not JSON (`:160-164`). A
fourth case is not an error: a contract that occupies no slot gets a table saying so
(`:328-329`, `:80-86`), and a contract that DECLARES no state variable of its own gets no frame at
all — `declaration_span` returns `None` and `deploy` prints a note and returns `Ok(())`
(`:451-458`). So "no storage frame" is a normal outcome for a library, an interface, an abstract
contract with only inherited state, and a proxy.

Two decisions follow. A storage failure must not fail the deploy: the global rule is that one item
failing is isolated, named, and does not cost the batch. The cluster is drawn, the failure is
printed with its reason, and because "first deploy" is decided by board presence (A.3), the next
deploy of that contract tries again. And `forge inspect` compiles the project, which on a cold
cache takes long enough to look like a hang; it belongs where the rendering already is — in the
local planning half, before any API call, so that a failure is known before the column's geometry
is decided. That matters because the storage frame sits to the LEFT of the first cluster: its width
has to be known to place the cluster, or the cluster goes at the column's left edge and the storage
frame is hung further left in a place that may already be the previous column. Planning storage
first (forge, then render, both local) gives its width; when it fails or there is nothing to draw,
no room is reserved and the cluster takes the column's left edge.

### A.8 An entry point of a contract that has no storage at all

Covered by A.7: the column has a label and clusters and no storage frame, and reserves nothing on
the left. The one wrinkle is a LATER `bat-cli storage --contract X` by hand after a change makes
the contract hold state: today it would go `beside_cluster`, i.e. right of and below the cluster,
not left of it. That is a stage-5 concern (the standalone commands becoming column-aware) and not
a reason to reserve an empty slot on every column.

### A.9 Overlap with another column or with the `overview` frame

Columns are x-disjoint by construction only if every new column opens to the right of every frame
on the board, and only if every cluster is still checked against the live board (A.2). The
`overview` frame is the thing that breaks the picture: it is "49 files wide" (`auto_deploy.rs:1554-
1557`), `free_spot` puts it below everything (`overview.rs:269-291`), and once it is there every
column's downward growth hits it. The push-down rule survives this — each column's next cluster
lands below the overview, in its own x-band — but every column is then split by the overview's
height. The fix that does not involve the push-down at all is to send the overview, like a new
column, to the RIGHT of everything rather than below it; `free_spot` is one function and the
change is a few lines. It is a question (C.6) because it changes where an existing command puts
its output.

The push-down is still needed regardless: frames the auditor drew by hand, frames from other
tools, and clusters deployed before this feature are all obstacles the column rule cannot
predict.

### A.10 Clusters deployed before this feature exists

Every existing record has `cluster_root` and `entry_point`, so its contract NAME is recoverable;
its FILE is not (A.6). Its frames are wherever `below_everything` put them, in a staircase down the
board. There is no safe automatic migration: moving a frame moves what the auditor arranged, and
guessing a file for an old record would be exactly the name-is-identity mistake. The honest rule
is that old clusters stay where they are and stay addressable (`--deployment`, `relink`,
`screenshot` all keep working, since none of them cares about columns), and a column exists only
for contracts deployed after the feature. Redeploying an old entry point puts its NEW cluster into
the contract's column and drops the old records as it does today. New columns open to the right of
everything on the board, so the old staircase is to the left and the columns to the right, which
is readable.

For the registry this means a new `columns` list under `miro.auto` with `#[serde(default)]`, and
`miro.auto.region` left in place as a defaulted field that nothing reads any more (serde ignores an
unknown key on read and `ShelfState` already defaults its newer fields, `bat_metadata.rs:247-260`).

### A.11 The outline inside a cluster, and the shelf's row wrapping

The outline is per cluster and stays as it is: `plan_cluster` is given an allocator
(`auto_deploy.rs:2260-2266`) and only reads `origin_x/origin_y` from it, so a column hands each
cluster `ShelfAllocator::new(column_left, column_cursor)` and the cluster lays itself out
underneath exactly as today. Two interactions need care.

Row wrapping at 60 000 is what bounds a cluster's width (A.2), so it must stay on in column mode;
it is the only thing that keeps a wide cluster from marching across every column. The indent per
depth (2 000, `layout.rs:644`) starts a row at `depth * indent`, so a deep cluster's left edge is
not the column's left edge but up to `max_depth * 2000` to the right; the column's derived LEFT
should be the minimum left of its live frames, which is the root's, and that is what the label and
the storage frame align to.

The root's row is closed to further frames when the root has cards (`has_children` →
`row_closed`, `layout.rs:706`), which is exactly the row the effects frame wants to join (the user
wants it beside the root, not beside the cluster). The allocator's fields are all `pub`, so the
plan can place the root with `has_children = false`, place the effects frame on the same row at
depth 0, and then set `row_closed = true` by hand; the row's height becomes the taller of the two,
which is what keeps a tall effects report from reaching into the children's rows. That is a
three-line change in `plan_one`/`plan_cluster` and a unit test in `layout_test`.

### A.12 `--undeploy`, `--replace`, `--refresh-links`, and the label and storage frame they do not know about

Those three flags do not exist: §15 removed `--undeploy`, `--refresh-links`, `--redeploy` and
`--recycle` in 0.26.11, and `--replace` never existed; the only "replace" is deploying again with
`--yes` (`main.rs:129-132`, `auto_deploy.rs:905-915`). CLAUDE.md's architecture section still
describes the old flags and `--refresh-links`; that paragraph is stale and worth a doc-only hotfix
on its own, because an assistant reading it will look for flags that are gone. What does exist and
does not know about columns:

`relink` (`auto_deploy.rs:7162`, `reanchor_frame` at `:7328`, `rebuild_record` at `:7386`)
re-pairs a cut-and-pasted frame by its TITLE from `list_frames`. A storage frame has a title
(`storage: X`) and can be re-paired the same way if the column record holds its id; a label is not
a frame and cannot be, so after a cut-and-paste of the label the id is stale, `item_status` says
404, and the next deploy draws a new label — leaving the pasted one as an orphan. Acceptable, and
worth one line in the guide.

The replacement path drops records by `stale_ids`, filtered on `cluster_root == title`
(`:879-889`), and `save_frame_record` retains by `(cluster_root, entry_point)` (`:3317-3326`).
If the storage frame and the label were stored as `AutoDeployedFrame` records they would need a
`cluster_root` that no deployment ever uses, and every filter over `frames` would have to learn to
skip them (the `previous` count at `:896-901`, `select_targets`'s `deployed` set at `:1176-1181`,
`beside_cluster`, the restore loop). That is the argument for a separate `columns` record rather
than more flags on `AutoDeployedFrame`. The effects frame is the opposite case: it IS part of a
deployment and should be a frame record with `cluster_root` = the entry point, so the same
`stale_ids` filter retires it for free; it needs one `#[serde(default)] effects_frame: bool` so
the restore loop and `select_targets` can skip it, exactly as `type_frame` is skipped.

Also worth knowing before the column starts replacing storage frames: `storage.rs:504-516`
deletes the previous `storage:` frame with `delete_item` and its comment says "deleting the frame
takes its contents with it", while §8 records the opposite, verified empirically, for the API
("deleting a frame does NOT cascade to its children"). One of them is wrong; if §8 is right, every
re-run of `storage` leaves two orphaned images on the board. Check it on a scratch board before
relying on either.

### A.13 Concurrency

Pass 1 creates every frame of a deployment concurrently from positions the plan already fixed
(`auto_deploy.rs:955-1000`), pass 2 fills up to `CONCURRENT_FRAMES` = 16 at a time (`:1016`,
`:1947`), the client allows 48 in flight (`client.rs:46`). None of it looks at the board, which is
why it is safe: every position was decided locally. The column feature keeps that property only if
the label, the storage frame and the effects frame are PLANNED like everything else — their sizes
rendered locally, their positions decided before pass 1, their frames created in pass 1 with the
rest. The moment any of them calls `list_frames` and `beside_cluster` after pass 1 has started,
it is racing the frames being created and its scan is stale.

Registry writes are already serialised in-process by `METADATA_FILE_LOCK`
(`bat_metadata.rs:427`, `:480-491`), and every write goes through `update_metadata`; a column
record update is one more closure under the same lock. Two bat-cli PROCESSES are not protected,
and the shared temp dir makes two concurrent deploys of one project delete each other's PNGs (§15,
§22); columns add one more shared thing — two runs would both decide "first deploy of X" and both
draw the storage frame. Known, and the existing answer stands: run one at a time.

### A.14 What the registry must persist for a column to survive a rescan

`from_contracts` preserves `miro` whole as raw JSON (`bat_metadata.rs:548-557`), plus
`ignored_contracts` and `resolutions` by name (`:558-572`). So anything placed UNDER `miro.auto` is
preserved for free, and anything added at the top level of `EvmBatMetadata` is lost on the next
`sonar` unless `from_contracts` is taught about it. The column record belongs under
`miro.auto.columns`, and that is the whole of the preservation work.

The record itself: the contract's name and file (the key), the label's item id, the storage
frame's id when one exists, and nothing geometric. The frame record gains the root file (A.6) and
the effects flag (A.12). The effects frame's id lives on its own frame record. Every new field is
`#[serde(default)]`, which is the convention the struct already follows for everything added after
the first version.

---

## B. A staged plan

Each stage compiles, ships as its own release, and leaves `deploy` working for someone who does not
want columns. The order puts the one decision that cannot be unit-tested on a real board — "derive
the column from the live board, test every placement against it" — in the first stage that touches
the board, and keeps the three decorations (label, effects, storage) behind it so a wrong
placement rule is found with one cluster, not with a cluster plus three satellites. Every stage
updates `src/guide.rs` (`WORKFLOW` and a `CHANGELOG` entry with its `_Re-read:_` line) in the same
commit, as CLAUDE.md requires; the guide's `effects` and `storage` sections (`guide.rs:554-566`,
`:597`) and the "every deploy is fresh… in a clean region below everything" paragraph (`:670-680`)
are the ones that go stale.

### Stage 0 — the placement arithmetic, pure, with tests

Changes: a `ColumnLayout` (or similar) in `layout.rs`, next to `ShelfAllocator`, with no client
and no registry. Given a list of obstacle rectangles (the live frames), a column's left edge and
current bottom, and a cluster's bounding box, it returns the cluster's origin: at the column's
cursor, pushed down until it clears every obstacle, with the gutter. Given the obstacles alone it
returns where a NEW column opens: right of everything, top-aligned with the existing columns' top.
Given a cluster's planned frames it returns the bounding box the two functions above consume
(`plan_cluster` already has every `frame_x/y/width/height`).

Must not break: nothing — nothing calls it yet.

Verified by: `cargo test` in `layout_test`, in the style of
`test_shelf_allocator_never_overlaps_and_wraps_rows` (`layout.rs:932`): a cluster wider than its
column lands below the neighbour and overlaps nothing; a second column opens right of the widest
frame on the board; a push-down against a board-wide obstacle (the overview) lands under it; an
empty board puts the first column at the origin.

### Stage 1 — clusters go into columns; nothing else changes

Changes: `miro.auto.columns[]` with the key only; `AutoDeployedFrame.root_file`; `run` derives
each column's geometry from the one `list_frames` it already does, assigns the target's column (or
opens one), builds the cluster's allocator at the column's cursor, plans, computes the bounding
box, runs the stage-0 push-down, shifts the plan's `frame_x/y` by the resulting delta, then pass 1
and pass 2 unchanged. `miro.auto.region` stops being written. `--dry-run` prints the column the
cluster would join and its origin, from a synthetic obstacle list, instead of `(0, 0)`.

Must not break: the outline inside a cluster (same `place_in_outline`, same order); the
replacement semantics and `stale_ids`; `relink`, `screenshot`, `highlight`, `effects --deploy`
and `storage` (they read frames by id and title, which are unchanged); old `BatMetadata.json`
files (new fields default); clusters deployed earlier, which stay where they are.

Verified without deploying: the stage-0 tests; a `--dry-run` of two entry points of one contract
and one of another, checking the printed origins share an x for the first two and not for the
third; a `--dry-run` against a `BatMetadata.json` whose `miro` section is from 0.26.49, checking
it loads and the columns list is empty. The first real deploy after this stage is the one that
proves the push-down against a live board; it is the riskiest moment of the whole feature and it
should be done with one small entry point, twice, before anything else is built on it.

### Stage 2 — the label

Changes: `MiroClient::create_label` (a `shapes` item with no `parent`, which is
`create_card` minus the parent and the link, `client.rs:1040-1080`; or `sticky_notes`, see C.3)
and the column record's `label_id`; the plan reserves the label's height above the column's first
cluster when the column is new; each run checks `label_id` with `item_status` and recreates the
label on a definite 404, keeps the record on `None`.

Must not break: stage 1's placement — the label is not a frame, so Miro will not refuse it, and
`list_frames` will not see it; the reservation above the column is the only geometric change.

Verified without deploying: a unit test that a new column's first cluster sits one label-height
plus gutter below the column's top; `--dry-run` printing `label: new` or `label: <id>`.

### Stage 3 — effects beside the root, automatically

Changes: split `effects` (`auto_deploy.rs:305`) into `effects_report(metadata, contract, function,
file, options) -> (title, sections)` — local, it already is — and `deploy_effects` taking a frame
id instead of finding a spot; `plan_cluster` renders the report's PNGs with the root plan (the
raw `build_graph` output, before framing cuts and copies the nodes), places the effects frame on
the root's row (A.11), and emits an `effects` plan; pass 1 creates it, pass 2 fills it; its record
has `cluster_root` = the entry point and `effects_frame: true`, so a redeploy retires it with the
cluster. `effects --deploy` standalone keeps working and gains the same replacement-by-title that
`storage` has. A `--no-effects` flag on `deploy` (C.4).

Must not break: `--dry-run` and `--preview` (no board, so no effects frame is created; the plan
still prints it); the restore loop and `select_targets`, which must skip `effects_frame` records;
the `unresolved` stop, which already runs before any plan is accepted, so the report is never
built on a tree the deploy refused.

Verified without deploying: unit test on the allocator for "root and effects share the row, the
row's height is the taller, the children start below both"; `--dry-run` printing the effects
frame's size and position; `cargo test` for `effects_report` on a fixture if one exists, else the
existing `effects` output compared before and after the split on one real project (it is pure).

### Stage 4 — storage on the first deploy of a contract

Changes: split `storage::run` into `plan_storage(metadata, contract) -> Result<StoragePlan>`
(forge, attribution, render, all local; `storage.rs:78-100` and the render half of `deploy`) and
`draw_storage(plan, frame_id)`; `pick_contract` and `forge_layout` accept the file and pass
`path:Name` to forge (A.6); in `run`, when the column has no live storage frame — none recorded,
or recorded and `item_status` says 404 — plan it, reserve its width to the left of the first
cluster, create its frame in pass 1, fill it in pass 2, record its id on the column. A failure is
printed with its reason and the deploy continues (A.7); `lib/` roots skip it.

Must not break: `storage --contract X` standalone and `--dry-run`; a column whose contract has no
storage (no reservation, no frame, no error); a deploy on a machine without `forge` (a note, not a
failure).

Verified without deploying: `storage --dry-run` output unchanged; a `--dry-run` deploy printing
`storage: planned <w>×<h>` or `storage: skipped (<reason>)`; a unit test that the first cluster's
left is the storage frame's right plus the gutter when a storage plan exists and the column's left
when it does not. The forge failure path can be exercised by renaming `forge` out of `PATH`.

### Stage 5 — the standalone commands learn the column

Changes: `storage --contract X` places its frame at the column's storage slot when X has a column
and no live storage frame, and replaces the existing one in place otherwise; `effects --deploy`
places beside the root frame of the latest deployment of that entry point; `overview` opens to the
right of everything (if C.6 says so). CLAUDE.md's stale paragraph about `--refresh-links` and
`--undeploy` is corrected (doc-only, but it rides with this stage's guide update anyway).

Must not break: anything — these are placement changes to commands that already check the board.

Verified by: `--dry-run` on `storage`; a unit test per placement rule.

---

## C. The questions that change the code

**C.1 Is a column keyed by contract name, or by name and file?** Recommended: name and file, the
label showing the path only when the name is shared on the board. CLAUDE.md's rule exists because
`lib/` vendors copies, and a column that merges two `BeaconProxy`s would be the first place the
board contradicts the diagrams inside it. The cost is one `root_file` field on the frame record and
teaching `storage` to take a file (A.6).

**C.2 What happens when a cluster is wider than its column?** Recommended: push it down the same
column until it clears, with a note in the output, and reserve the outline's own row width
(60 000 plus a gutter) as the pitch when a column opens so it is rare. Not a continuation column
(breaks the one-column-per-contract reading) and not a refusal (the auditor's rule: size is never
a reason to draw less). This is the stage-0 arithmetic and the only part that has no second chance
once a frame is created.

**C.3 Is the label a sticky note or a shape?** Recommended: a shape, created with the `shapes`
call `create_card` already uses, minus the parent and the link. The wrapper has no sticky-note
call today (the endpoints it uses are `items`, `frames`, `images`, `shapes`, `connectors`); Miro's
REST `sticky_notes` exists, but a sticky's text auto-fits a small fixed-aspect square and reads at
the zoom of one frame, while the label has to read at the zoom where a whole column fits, which
means a wide shape with a large `fontSize` — the same reason link cards are shapes. If the user
specifically wants the sticky look, it is one new client function and the same record; the
placement code is identical. Either way it has no parent, because a frame cannot sit above a
column.

**C.4 Is effects always drawn, or opt-in?** Recommended: always, with `--no-effects` to skip, as
asked. The report is local and cheap next to the deploy, and its frame joins the deployment so a
redeploy retires it. The question that actually changes code is whether it is a FRAME RECORD of
the deployment (recommended, so `stale_ids` and `--deployment` reach it) or an id on the root's
record (fewer filters to teach, but a second way to own a frame).

**C.5 What does "first deploy" mean for storage, and what does a storage failure do?**
Recommended: "this column has no live `storage:` frame on the board", decided by `item_status` on
the recorded id and by title when there is no record; and a failure (no `forge`, no compile, no
declarations) prints its reason, skips the slot, and lets the next deploy try again. The
alternative — a `storage_done` flag — never retries after a hand-deletion or a fixed build, and
stopping the deploy violates the one-error-does-not-sink-the-batch rule. Also to decide: whether a
`lib/` root gets a storage frame at all (recommended: no; `pick_contract` already excludes
vendored contracts and a dependency's storage is not what the audit is about).

**C.6 Where do new columns open, and does `overview` move?** Recommended: new columns open to the
RIGHT of everything on the board, top-aligned with the existing columns, and `overview` goes to the
right of everything too instead of below. Below-everything is what makes the overview a wall across
every column's path (A.9). The old staircase of pre-feature clusters stays where it is, to the
left. If the user would rather keep `overview` where it is, the push-down handles it and every
column shows a gap the overview's height — readable, not pretty.

**C.7 Is column geometry derived from the board every run, or persisted?** Recommended: derived,
from the `list_frames` call `resolve_allocator` already makes once per run, with the registry
holding only identity. Persisting a cursor is what `region` does today and it is reset before
every run precisely because it cannot be trusted after the auditor touches the board (A.4). The
cost is nil — the scan is already paid for — and `miro.auto.region` becomes a defaulted field
nothing reads.

**C.8 Are pre-feature clusters migrated?** Recommended: no. Moving frames the auditor arranged is
the trade `relink` explicitly refused, and an old record carries no file to key a column by. Old
clusters stay addressable; the first redeploy of each entry point moves it into its column, which
is the migration path the code already has.

## Pending, deliberately not done yet

**Purging the registry of frames the auditor deleted.** A deploy now reads the board once, so it
could cheaply drop every `miro.auto.frames` record whose frame no longer exists there. Placement
does not need it — a column is derived by crossing the registry with the live frames, so a
deleted frame already holds no space — and the only thing that notices is `bat-cli highlight
--list`, which keeps offering a frame that is gone. Left out on purpose: highlighting is done by
someone who knows which frames they have in front of them, and a command that silently rewrites
the registry on every deploy is a bigger thing than the problem it fixes. Pick it up if the stale
entries ever become noise.