carlos / platform Public

Design spec for #212: key box objects by a collision-resistant id

status.Label maps a machine id to one of 1,600 labels and both per-box
objects (status/boxes, control/edges) are keyed by it, so two boxes can
overwrite each other. The spec keys both objects by a 128-bit digest of
the machine id, keeps the public label for people, joins readers by id
with a label fallback for one release, treats duplicate identities as
incomplete evidence, sweeps retired boxes' objects, and rules that the
host lifecycle claim must name boxes by the id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 71112c9e2eb436946555e4698cf3b9a339baf631 parent 04cd2d7
1 file changed, +423 −0
  • docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md +423 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md b/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md
new file mode 100644
index 000000000..7f2ad9d1d
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md
@@ -0,0 +1,423 @@
+# Box identity: key box objects by a collision-resistant id, keep the label for people
+
+**Date:** 2026-10-06
+**Revision:** 1
+**Discussion:** amadan carlos/platform #212
+**Status:** draft, for Paul's review after Astra's.
+**Builds on:** the fleet status page (`internal/status`), the multi-region
+ingress edges file (2026-08-20, Part 3), nearest-region placement
+(`2026-10-01-nearest-region-default-design.md`), the host lifecycle claim
+(`2026-10-02-host-lifecycle-claim-design.md`, on `design-replica-ownership`).
+
+## 1. The problem
+
+`status.Label` (`internal/status/snapshot.go:523`) maps a machine id to one
+of 40 names x 40 actions, 1,600 labels. The label is the object key for the
+two per-box objects every platform box writes:
+
+- `status/boxes/<label>.json` (`snapshot.go:747`)
+- `control/edges/<label>.json` (`internal/host/edgesfile.go:36`)
+
+Two boxes with one label overwrite each other. Birthday odds are about 0.4%
+for four boxes, 1% for six, 6% for twenty. Astra found a concrete pair:
+machine ids `00000000000000000000000000000018` and
+`0000000000000000000000000000003c` both give `edsger-knitting`.
+
+When it happens: `edgesfile.EdgeIPs` loses one box's address; the fleet
+page shows one box where there are two; nearest-region eligibility joins
+edges files to snapshots by label and can read a keyless box as eligible;
+the edges writer's unchanged-file gate (`edgesfile.go:491`) can leave the
+wrong box's file in place indefinitely, because the gate compares against
+memory, not the bucket.
+
+The label is public (fleet.json, the status page) and must stay
+non-reversible from the machine id. That contract is kept. The label is
+not changed. What changes is what the objects are keyed by, and how readers
+join them.
+
+## 2. Every consumer of the label-keyed objects
+
+### 2.1 Writers
+
+| Where | What |
+|---|---|
+| `internal/status/snapshot.go:523` `Label` | the hash; `:674` CollectBox sets `Box.Label`; `:742` WriteBox keys by it |
+| `internal/host/agent.go:2905` `statusTick` | `:2926` fleet boxes override the label with the operator-chosen `fleet_boxes.label` |
+| `internal/host/edgesfile.go:273` `edgesFilePass` | writes `control/edges/<box>.json`; `:158` `Box` field; `:491` memory-only change gate; `:529` `seedEdgesFileGate` reads the key once at arm; `:637` the box name is `metricsBoxLabel` |
+| `internal/host/metrics_loop.go:729` `metricsBoxLabel`, `:743` `BoxLabel` | the one selector: fleet label on a fleet box, else `status.Label` |
+| `internal/host/agent.go:542` `edge.WithBoxLabel` | the edge's notion of self for the home map |
+
+The same selector names the box in other per-box keys. They are not the
+subject of #212 but collide the same way and are listed so the fix lands in
+one place (§3.5, step 2):
+
+| Key | Where |
+|---|---|
+| `metrics/boxes/<box>/raw/<hour>.json`, `/days/<date>.json` | `internal/metrics/chunk.go:25,31`; writer `metrics_loop.go:136` |
+| `alerts/boxes/<box>.json` | `internal/metrics/alert.go:120` |
+| `metrics/relay/<box>/<hour>.json` | `internal/relay/meter.go:26`; `relay.go:138` |
+| `apps/<sqid>/<app>/{logs,errors,analytics}/<box>/...` | `internal/applog/chunk.go:58`; sinks at `agent.go:244,245,477,480,569` use `status.Label` directly |
+| usage flush | `internal/host/usage_loop.go:72` |
+
+### 2.2 Readers of `status/boxes/`
+
+| Where | Join or key |
+|---|---|
+| `internal/status/fleet.go:238` `loadBoxesReport` | the ONE parser; `:150` `LoadBoxes`, `:234` `LoadBoxesComplete` |
+| `fleet.go:157` `BuildFleet` | public feed; `:173` copies `Label` into `FleetBox`; `:563` `PublishFleet` writes `status/fleet.json` |
+| `internal/edgesfile/fold.go:46` `EdgeIPs`, `:125` `DeploymentRoutes` | join `live[b.Label]` to `f.Box` (`:64,84,137,158`) |
+| `internal/console/instancesplacement.go:93` `readLiveEdgesSnapshot` | `known[b.Label]`, `live[b.Label]` (`:120,122`) joined to `f.Box`; feeds automatic eligibility |
+| `internal/console/view.go:854` `HostRows` | operator fleet page rows by label; `templates/fleet.html:47,109` |
+| `internal/console/apiinstances.go:368` `loadBoxJoin` | staged_version, upstreams, db_bytes: joined by hostname inside `Metrics`, label incidental; callers `apiinstances.go:422`, `apiroutes.go:151`, `apisidecars.go:150` |
+| `internal/console/apidomains.go:1144` | alias DNS and cert readings, by hostname |
+| `internal/console/apisystemupdate.go:233,258,764` | `carlos system` rows by label; CLI `system.go:207,438` |
+| `internal/console/archgraph.go:561` `buildBoxCards` | keyed by label (`:574,580`), node id `"box:"+label` (`:807`), machine join by `CloudID` (`:271`); `archregion.go:59` |
+| `cli.go:3389` `statusLive`, `:3418` | `carlos status -live`, label-sorted |
+| `internal/status/dryrun.go` | the leak scan; `:182` fixture machine id `ip-10-0-0-7` |
+| `internal/console/fleets.go:1012,1030` | fleet heartbeats under `fleets/<name>/status/boxes/<label>.json`, joined by the operator's `fleet_boxes.label`. Out of scope (§3.6) |
+
+### 2.3 Readers of `control/edges/`
+
+| Where | Join or key |
+|---|---|
+| `internal/edgesfile/edgesfile.go:68` `File`, `:81` `Parse` | the shared reader |
+| `internal/edge/homemap.go:130` `edgesFileView`; `:273` `refreshHosts`; `:538` `foldHomes` | skips the file whose `Box == self` (`:547`); keys the file map by object key |
+| `internal/host/steering_loop.go:50` `loadEdgesFileBodies`; `internal/steering/deployment.go:56` | fails closed on a short read; folds by EIP and hosts, not by box |
+| `internal/console/instancesplacement.go:38` view, `:175` `loadEdgesJoin` (`served_by.box`, `:179`), `:232` `readEdgesSnapshot`, `:320` `placements` | the create door and the instances API |
+| `internal/host/movespass.go:335` `moveTargets` | one target per placement, tie broken by `Box` (`:346`) |
+| `internal/console/server.go:906` `edgeFold`; `internal/host/agent.go:133` | `EdgeIPs` for the custom-domain DNS check |
+| `internal/edge/storedoor.go:411` | everything under `control/` is readable by fleet boxes through the store door |
+
+### 2.4 The public fleet feed and its privacy contract
+
+`status/fleet.json` is served by the edge (`internal/edge/status.go:54`)
+and rendered by `internal/status/assets/index.html:173`. It carries the
+label and nothing that identifies a machine (`snapshot.go` package doc,
+README "Fleet status"). `BuildFleet` copies `Box` fields into `FleetBox`
+one by one and never names `Metrics`; `dryrun.go` asserts a raw value
+survives into the box object and never into fleet.json or index.html.
+
+The contract this spec keeps: fleet.json carries the label only. The new
+id never appears in fleet.json, index.html, or any account-facing page
+(`accountfleet.go:18` reads fleet.json only). Both label-keyed objects are
+bucket-private. `control/edges/` is readable by customer fleet boxes
+through the store door; that is already true of the label and the EIP.
+
+## 3. The design
+
+### 3.1 The box id
+
+```go
+// BoxID is the collision-resistant key for a platform box's objects:
+// the first 16 bytes of sha256(machineID), lower-case hex, 32 characters.
+// Label is drawn from bytes 0..8 of the same digest, so a reader can
+// check that a label belongs to an id: LabelForID(id) == label.
+func BoxID(machineID string) string
+func LabelForID(id string) (label string, ok bool)
+```
+
+- 128 bits. A collision needs about 2^64 boxes. The label's 10.6 bits are
+ the whole problem; 64 bits would already do, 128 costs nothing and makes
+ the hex id visibly not a label. Fixed length, lower-case hex, so it is a
+ valid key segment in S3, the DirStore (`store.go:269`) and the store
+ door, and it can never be confused with a `name-action` label or a fleet
+ label (`registry.ValidTunnelName`).
+- Non-reversibility is the same argument the label makes: sha256 over a
+ 128-bit random `/etc/machine-id`. Exposing more digest bits does not
+ weaken a preimage. The honest difference: for a low-entropy machine id
+ (the dry-run fixture `ip-10-0-0-7`, a container that hands out a
+ hostname), the id confirms a dictionary guess where the label only
+ narrows it to 1 in 1,600. That is why the id stays bucket-private (§2.4)
+ and the leak scan gets a needle for it (§5).
+- An empty machine id (`probes.go:153`, unreadable file) gives no id. The
+ agent then behaves exactly as today: label key, no `box_id` field. Readers
+ treat it as an old writer. This is the one case the id cannot be minted,
+ and it is already the case `Label("")` documents as "this probe never
+ worked".
+- The label is unchanged. `Label` keeps its function and its table so the
+ public label of every existing box is stable through the roll.
+
+### 3.2 Where the id lives and how it travels
+
+One selector replaces `metricsBoxLabel`:
+
+```go
+type BoxIdentity struct{ ID, Label string }
+// On a platform box: ID = status.BoxID(machineID), Label = status.Label(machineID).
+// On a fleet box: both are the operator-chosen fleet_boxes.label (§3.6).
+func boxIdentity(getenv func(string) string, machineID string) BoxIdentity
+```
+
+Status snapshot. `Box` gains `BoxID string json:"box_id,omitempty"`.
+`WriteBox` keys by `box_id` when present, else by label. `FleetBox` does
+not gain the field. The fleet-box override at `agent.go:2926` sets the
+label and leaves `box_id` empty.
+
+Edges file. `edgesFile` gains `BoxID string json:"box_id,omitempty"` next
+to `Box`. `edgesFileKey` takes the id when present, else the label. `Box`
+keeps the label; every reader that shows a box to a person keeps reading
+it. The new field is part of the change gate like every field but
+`Generation`.
+
+Both objects carry both. A reader that has both sides new joins by id. A
+reader that has an old side joins by label, as today.
+
+### 3.3 Readers: one join rule, in one place
+
+`loadBoxesReport` (`fleet.go:238`) stays the one parser and gains a
+dedupe step before returning, reported in a new `Report` for the
+completeness readers:
+
+1. Group objects by `box_id` when present. Two objects (two keys) with one
+ id is a **duplicate identity**: both are returned but flagged, `complete`
+ is false, and the condition is reported (§3.4).
+2. Among objects that share a label, exactly one of which has no `box_id`,
+ compare `Metrics.InstanceID`. Same instance id: one box, seen through an
+ old key and a new key (a roll or a rollback in progress). Keep the
+ fresher, mark the other **superseded**, do not count it. Different or
+ absent instance ids: two boxes sharing a label, one old writer. Both
+ stand; this is the collision the design exists for, and it is reported
+ while the legacy key exists for that label.
+3. Everything else passes through unchanged.
+
+A `BoxKey(b Box) string` helper returns `box_id` if set, else the label.
+The edges-file join uses it on both sides:
+
+- `edgesfile.EdgeIPs` and `DeploymentRoutes` (`fold.go:64,84,137,158`)
+ build `live` over `BoxKey(b)` and also over `b.Label` for boxes with no
+ id; a file joins on `box_id` when it has one, else on `box`. A file whose
+ `box_id` is set but whose label does not equal `LabelForID(box_id)` is
+ not a live edge: it was not written by this code.
+- `readLiveEdgesSnapshot` (`instancesplacement.go:120,122`): the same
+ rule, and the dedupe report folds into `BoxesComplete`.
+- `homemap.foldHomes:547` skips self by id when both sides have one, else
+ by label. Two files with one label and different ids both fold; today
+ the second would never have existed.
+- `moveTargets:346` ties on `BoxKey`.
+- `archgraph.go:574,580,807` and `archregion.go:59` key cards and nodes by
+ `BoxKey`; `HostRow` gains `ID`. The title stays the label; when two cards
+ share a label the private page appends the first four hex characters of
+ the id. fleet.json and the public page show two rows with one label,
+ which is the true count.
+
+Because every reader lists the prefix and reads identity from the body,
+the object key name is not load-bearing for any reader on main today. That
+is what makes the migration a one-release change (§4).
+
+### 3.4 Duplicate detection is incomplete evidence
+
+Two conditions, both reported by the one parser:
+
+- **Two objects claim one id.** Impossible for honest writers; it means a
+ copied disk image, a forged object, or a bug. Eligibility fails closed
+ for the whole request (`BoxesComplete=false`), the same posture as a
+ failed read. The fleet page and `carlos status -live` show both rows
+ with a `duplicate id` chip.
+- **One label has two ids while a legacy key exists for that label.** The
+ label-keyed objects are contested and nothing keyed by label can be
+ trusted for those boxes (the step-2 keys in §2.1 included). Eligibility
+ fails closed for the whole request. Once no legacy key remains for the
+ label it is a display collision only and stops being reported.
+
+Alerting reuses what exists. The status lease holder evaluates these two
+conditions in `BuildFleet` and publishes a rule `identity` in its own
+`alerts/boxes/<box>.json` (`metrics.AlertState`), firing while either
+holds. The console's alert watcher (`alertwatch.go:97`) then mails
+operators as it does for disk or memory, and clears when the condition
+clears. One log line per condition per process, as `livenessSaid` does.
+Nothing reaches fleet.json.
+
+### 3.5 Writers
+
+Step 1 (this release):
+
+- `WriteBox` and `edgesFilePass` write the id key.
+- **No dual write of the legacy key.** No reader needs it (§3.3), and a
+ second object for the same box would be counted twice by every old
+ reader: `sumInstances` includes stale boxes, so the public instance
+ total would double for that box, and `BuildFleet` would show two rows.
+ Dual-writing would create the symptom we are removing.
+- **Own legacy key cleanup, guarded.** After its first successful id-keyed
+ write, a box deletes `status/boxes/<label>.json` only if that object's
+ `metrics.instance_id` equals its own, and `control/edges/<label>.json`
+ only if that object's `eip` equals its own non-empty EIP. Otherwise the
+ legacy object is a colliding sibling's, or unprovable, and is left for
+ the sweep. Deleting a sibling's edges file would be the gate bug again
+ with the roles swapped. The check runs once per process start, so a
+ box that was rolled back and forward again cleans up after itself.
+- **The edges gate verifies against the bucket.** Every tenth tick with
+ no local change (about ten minutes), `edgesFilePass` reads its own key.
+ Missing or different from `*last` (Generation aside): republish. Nothing
+ else should write an id key, so this is defence in depth, and it is also
+ how a file removed by the sweep or by hand comes back without waiting for
+ a route change.
+- **Retired boxes.** The status lease holder runs an identity sweep on its
+ publish tick: a snapshot stale for `retiredAfter` (7 days) is deleted
+ together with the edges file that joins to it (by id, else by label); a
+ superseded legacy snapshot (§3.3 rule 2) is deleted at once, with its
+ legacy edges file when the EIP matches the fresh object's. An edges file
+ with no snapshot at all is reported, never deleted automatically: nothing
+ can date it. `carlos fleet retire <id|label>` deletes both objects by
+ hand after confirming what it found. Deletes name the key only; the
+ deployment bucket is versioned, so a wrong delete is recoverable.
+
+Step 2 (next release, its own plan): `boxIdentity().ID` replaces the label
+in every key of §2.1's second table, with the label carried inside the
+object as `box` is today (`RawChunk.Box`, `AlertState.Box`). Readers of
+those prefixes (`metricsview.go:140`, `alertwatch.go:131`, the logs API)
+take the segment as opaque and display the label from the body. This spec
+does not fix those collisions; it fixes the two objects that other
+machinery joins on, and gives step 2 its identity.
+
+### 3.6 Fleet boxes
+
+A fleet box's label is operator-chosen and unique within its fleet
+(`registry/fleets.go:26` `ErrFleetBoxActive`). Its heartbeat lands under
+`fleets/<name>/status/boxes/<label>.json` and the console joins it by
+`fleet_boxes.label` (`fleets.go:1012`). Fleet boxes do not run the edges
+loop. They are not in #212 and they do not change: `boxIdentity` returns
+the fleet label as both ID and Label, `box_id` is omitted, and the key
+stays the label.
+
+## 4. Migration with mixed versions during a roll
+
+The roll order does not matter. Each pair:
+
+| Writer | Reader | Join |
+|---|---|---|
+| old box (label key, no `box_id`) | new console or edge | `BoxKey` falls back to label. As today |
+| new box (id key, both fields) | old console or edge | lists the prefix, parses `label`/`box`, ignores the unknown field. As today |
+| new box | new reader | by id |
+| colliding old pair | any | still collides, until both roll; reported once one has |
+| colliding pair, one rolled | new reader | the rolled box has its own key; the legacy key is the old sibling's and is left alone (§3.5 guard); reported as a label with two ids while the legacy key exists |
+
+One release of readers that accept both shapes is this release. The
+fallback to label stays for at least one more release after it, and is
+removed only when the sweep reports no legacy key in production.
+
+Nothing customer-visible changes. The public label is the same string.
+fleet.json's shape is unchanged. The instances API's `served_by.box` keeps
+the label. Alert mails name the label.
+
+## 5. The host lifecycle claim
+
+`internal/hostclaim` is not on main at `04cd2d78`; it is on
+`feat/hostclaim-library`, and the spec is on `design-replica-ownership`.
+The claim names boxes in `holder.writer_box`, `op.exec`,
+`op.participants`, `touched.boxes`, `obligation.owner = "box:<b>"`,
+`admissions[].box`, and keys execution leases at
+`control/hosts/<host>/exec/<box>.json`. The spec's example is
+`"writer_box": "adi-knitting"`: a label. On the library branch
+`configAckFunc` already stamps `BoxLabel()` into config acks
+(`agent.go:942` there). Plan 3's box guard checks `c.Op.Exec != in.Box`.
+
+A label collision there is worse than here: a sibling with the same label
+passes "am I the executor", can renew or take the other's execution lease,
+and can complete or park the other's obligations. Ruling proposed: the
+claim and every later plan name a box by `boxIdentity().ID`, never by the
+label, before any claim-driven operation is armed. The claim carries no
+display name; a person reads the label through the fleet page. Step 1 of
+this spec lands first so the identity exists when the claim's plans build
+on it. No other label-keyed structure was found in the claim spec.
+
+## 6. Rollback safety
+
+A box rolled back below this release writes `status/boxes/<label>.json`
+and `control/edges/<label>.json` again. Its id-keyed objects remain.
+
+- A new console dedupes them by `instance_id` (§3.3 rule 2): the legacy
+ object is fresher, the id-keyed one is superseded, counted for nothing,
+ and deleted by the sweep on the next publish tick.
+- An old console shows the id-keyed snapshot as a stale second row for
+ that label until the sweep (run by whichever box holds the status lease;
+ if every box rolled back, 7 days, or by hand). Its instance counts are
+ included in the public total while it is there. That is the one visible
+ cost of rollback, bounded by the lease holder's tick when any box is on
+ the new release.
+- The rolled-back box's edges file is readable by every reader: `box`,
+ `eip`, `hosts` are unchanged. The id-keyed edges file lingers, carrying
+ the same EIP and hosts; the home map, steering and `EdgeIPs` fold it the
+ same way they fold the legacy one, with the same content, so nothing is
+ wrong, only duplicated. The sweep removes it with the superseded snapshot.
+- The rolled-back box's own gate (`seedEdgesFileGate`) finds no legacy
+ file if the forward run deleted it, and republishes on its first tick.
+
+Nothing on the new release writes a shape the old release cannot parse.
+
+## 7. Test plan
+
+Unit, in `internal/status`:
+
+- `BoxID` vectors: fixed digests; the known pair `...18` and `...3c` share a
+ label and get different ids; `LabelForID(BoxID(m)) == Label(m)` for a
+ table of machine ids; `BoxID("") == ""`.
+- `WriteBox` keys by id when set, by label when not; the body carries both.
+- `loadBoxesReport`: two keys one id (duplicate, incomplete); one label two
+ ids with a legacy key (reported, incomplete); one label, old and new key,
+ same instance id (superseded, fresher wins, complete); same with
+ different instance ids (both stand); old-only and new-only fleets
+ unchanged against the existing goldens.
+- `BuildFleet` with a superseded object does not double count.
+- `dryrun`: a needle for the id; it must appear in `status/boxes/*.json`
+ and never in fleet.json or index.html.
+
+Unit, in `internal/host` and `internal/edgesfile`:
+
+- `edgesFilePass` writes the id key; the gate includes `box_id`; the
+ tenth-tick bucket verify republishes a missing or foreign body.
+- Legacy cleanup: deletes only when instance id (status) or EIP (edges)
+ match; leaves a sibling's object; runs once per start.
+- `EdgeIPs` and `DeploymentRoutes` join matrix: old/new snapshot x old/new
+ file, plus a file whose label does not match its id (not live).
+- `moveTargets` with two files sharing a label and different ids.
+
+Unit, in `internal/console` and `internal/edge`:
+
+- `readLiveEdgesSnapshot` join matrix as above; the duplicate and
+ two-ids conditions set `BoxesComplete=false` and log once.
+- `foldHomes` self-skip by id, and two same-label files both fold.
+- `buildBoxCards` gives two cards for two boxes with one label, titles
+ disambiguated by id prefix; archregion seeds by key.
+- The alert watcher mails on the `identity` rule and clears it.
+
+Mixed-version goldens: a committed legacy-shape object for each prefix,
+parsed by every new reader; and a committed new-shape object asserted to
+keep `label`, `box`, `v`, `eip`, `hosts`, `generation` at the same JSON
+field names, which is what an old reader needs.
+
+Sweep: against a DirStore, retired after 7 days stale, superseded at once,
+orphan edges file reported and kept, `carlos fleet retire` removes both.
+
+The simulation habit from the moves work applies to §3.3's dedupe: a
+table-driven run over random fleets with injected collisions, rolls and
+rollbacks, asserting that a live box is never counted for nothing and a
+retired box never counts for eligibility.
+
+## 8. Rollout
+
+1. Land step 1 on main; cut a release.
+2. Roll the boxes in any order (the platform roll recipe). After each box,
+ `carlos status -live` shows its id and the fleet page shows one row per
+ box with no `duplicate` chip.
+3. Watch the lease holder's log for the identity sweep's first pass: the
+ legacy keys of rolled boxes disappear, nothing else does.
+4. Roll the console. Nearest-region eligibility keeps reading, now by id.
+5. After every box is on the release and the sweep reports no legacy key,
+ file the step-2 plan (metrics, alerts, relay, logs, usage) and the claim
+ ruling in §5 on the claim branch.
+6. One release later, remove the label fallback from the join (§4).
+
+## 9. Open questions for Paul
+
+1. Should fleet.json gain a short, non-reversible disambiguator (the first
+ four hex characters of the id) so two public rows with one label can be
+ told apart, or is two identical rows the honest public answer? This
+ spec keeps fleet.json label-only.
+2. `retiredAfter` is 7 days. Shorter makes a box that is down for a long
+ weekend lose its edges file and its steered records; longer leaves
+ stale rows. Is 7 right for the flagship?
+3. Step 2 changes the segment under `apps/<sqid>/<app>/logs/<box>/` from a
+ label to an id. `carlos logs` shows it today. Is that acceptable for
+ members, or should the logs API map it back to the label?
+4. The claim ruling in §5 is proposed here and belongs on the claim branch.
+ Do you want it recorded there now, or when step 1 has landed?