verify-landing-queue · git:20261103.c236681 · 2026-11-03 · sha256 e16006dadf5f3628

verify-landing-queue git:20261103.c236681A

Immutable. This exact content never changes and is served at /api/v1/blob/e16006dadf5f3628.

---
name: verify-landing-queue
description: "Check the landing queue and its path leases against the TLA+ model in verification/landing-queue/. Use when a change touches t3/added/landing/scripts/landing_lib/, before you trust a claim about lease overlap, queue order, one trunk writer, what merges, or recovery after a crash."
---

# Verify the landing queue

`verification/landing-queue/LandingQueue.tla` and the sibling modules beside it are
a hand-written TLA+ model of the landing queue and its path leases. TLC checks the
model inside a stated bound.

TLA+ checks the model. It does not read the Python. A rerun of an unchanged model
passes after a real code regression, with the same state counts. A passing rerun
alone does not show the model matches the current code.

Before a passing run counts, and before `Verified at` moves, compare each affected
action and its helper assumptions with the changed code and update the model to
match. Start from the diff, not from the table. Run `git diff --stat <Verified at>
..HEAD -- t3/added/landing/scripts/landing_lib/` and read every changed function
against its action before you run anything. A function can change behaviour without
moving a transaction boundary and without appearing in the table's right column.

Read every result with the pstack `/statechart` skill. It turns an invariant
violation into the shortest path to it in plain words, and draws the state graph
when nothing breaks. Use it rather than reading `tlc.out` yourself.

## Does my change touch the verified code?

Compare the model and run the check when a change touches any of these, in
`t3/added/landing/scripts/landing_lib/`.

| Code | Actions in the model | Promises |
| --- | --- | --- |
| `leases.py`: `lease_claim`, `lease_renew`, `lease_release`, `lease_widen` | `Claim`, `Renew`, `Release`, `Widen` | P1, P2, P6 |
| `_leases/store.py`: `admission`, `in_flight` | `Clash`, `InFlight` inside every lease write | P1 |
| `_leases/owner.py`: `check_owner`, `raise_floor` | `Stale`, `OwnerAllowed`, `RaiseFloor` | P2 |
| `seam.now` and lease expiry | `ClockTick` | P1, P5b, P6 |
| `_queue/entries.py`: `submit` | `Submit`, `SubmitDuplicate` | P2, P5b |
| `drain.py`: `land` and the `.queue.lock` flock | `DrainStart`, `DrainEnd`, `Crash` | P3a |
| `_queue/entries.py`: `reconcile` | `ReconcileFetch`, `ReconcileAttempt`, `ReconcileLanding` | P3b, P5a, P5c |
| `_queue/entries.py`: `attempt`; `_queue/prs.py`: `build`, `build_line` | `IntegrationReset`, `SettleAlready`, `BounceApply`, `BounceCheck`, `RecordLanding`, `BeginPublish`, `AbandonAttempt`, `SettleLanded`, `RecordCandidate` | P3b, P4, P5 |
| `_queue/entries.py`: `publish` | `Publish`, `PublishRefused` | P3b, P3c |
| `_queue/entries.py`: `settle_landed`, `settle_bounced` | `Landed`, `Bounced` inside every settling action | P4, P5b |
| `_queue/integration.py`: `apply`, `check` | `ApplyRun`, `CheckRun` | P4, P5c |
| `_queue/prs.py`: `ensure_pr` | `OpenPr`, `StorePr` | P4 |
| `_merge/rollup.py`: `absent_since`, `absence_waits` | `AbsenceSeen`, `GraceEnds` | P4 |
| `_merge/required.py`: `request_merge` | `RequestMerge` | P4 |
| `_merge/settle.py`: `take_pr` | `SettleMerged` | P3b, P4 |
| `_merge/settle.py`: `bounce_open_pr` | `BounceOpenPr` | P4, P5b |

These actions stand for an actor and not for our code, so read them when an
assumption about that actor changes.

| Actor | Actions |
| --- | --- |
| The forge | `ChecksPost`, `ChecksFinish`, `HeadMoves`, `ForgeCompletes` |
| Another writer on trunk | `OutsiderPush` |

`ReleaseThenSettle` stands for no code. It is the broken order behind one variant.

The model also leans on helpers. These are `foundation.overlaps`,
`foundation.covered`, `foundation.fingerprint`, and `Store.tx`. It assumes overlap
is intersection over atomic paths, coverage is a subset test, a fingerprint is equal
exactly when a replay carries the same change, and a transaction is all or nothing.
A change to any of them can invalidate a promise without moving a transaction
boundary. So can a change to the values `land.py` passes to these functions.
Message-only changes do not need this comparison.

If the change moves a transaction boundary, adds or removes a `Store.tx()`, adds an
external call between two transactions, or changes when the queue lock is taken,
the model needs updating even when the table does not name the function. Say which
transaction moved.

## Run it

One command, from the repository root:

```sh
python3 skills/landing/scripts/land.py slot -- python3 verification/landing-queue/check.py --scratch-parent <parent>
```

`--scratch-parent` is required and has no default. It takes the machine's scratch
directory, the one a brief or the restaurant's `scratchDir` names. Any existing
writable directory works, so when neither names one, make one with `mktemp -d`
and delete it afterwards. `check.py` creates a new directory under the parent for
each config's TLC state and deletes that directory, by its exact path, when TLC
exits. With the argument missing it prints a usage error naming
`--scratch-parent` and runs nothing.

It runs five base configs and every config in `variants/`, and exits 0 only when
each answers what the model says it should. It prints one row per config with the
states generated, the distinct states found, the deepest level, and the wall time.

Bounds. TLC explores with one worker in breadth-first order. `Depth` is 12 in
`leases.cfg`, 30 in `publish-one.cfg`, 32 in `merge-one.cfg`, 24 in `publish.cfg`,
and 18 in `merge.cfg`. Each variant sets its own depth. The bound is enforced in
`Next`, and there is no `CONSTRAINT`, so no violation is reported past `Depth`.
`leases.cfg`, `publish-one.cfg`, and `merge-one.cfg` run out of new states before
`Depth`. `publish.cfg` and `merge.cfg` stop at it. What is left out is listed in
`verification/landing-queue/README.md` under "What the check does not cover".

A base config must report `holds`. A variant must report `breaks <invariant>` with
the invariant its `EXPECT:` line names. A variant's result counts only while every
base config holds. When a base config breaks, the variants that name the same
invariant may be breaking through that bug rather than through the rule they remove,
and `check.py` cannot tell the difference.

## Tell a real bug from a modelling error

The model is written by hand from the code, so it can describe the design and miss
the code. Before you report a bug, check these in order.

1. Does the model's action match the function the map in the model's header names,
   line for line? A missing guard, a guard in the wrong order, or two transactions
   folded with no reason in the map is a modelling error. Fix the model, then
   re-run.
2. Is the counterexample a sequence the code can really produce? Read each step
   against the function it names. A step the code cannot take is a modelling error.
3. Only then is it a bug in the code. Do not weaken the invariant to make it pass
   and do not change `landing_lib/` to match the model. Write the counterexample as
   a numbered list of steps in the code's words, reproduce it in a throwaway
   repository and store, and file it as `Correctness:` or `Safety:`.

A variant that reports `holds` is a modelling error in the variant. A base config
that reports `breaks` is a finding against the code, or a modelling error, decided
by steps 1 and 2.

## When the change is intended

1. Read the code at the new commit, the action that covers it, and the helper
   assumptions above.
2. Update the action so it still stands for one transaction or one external call,
   or say in the map why a fold cannot change a promise. If a guard moved, move it.
   Keep a check's pass or fail outcome in the model even when the code stops acting
   on it. The failure comes from outside the program, and dropping it deletes the
   case that proves the promise.
3. If a promise changed, update `verification/landing-queue/README.md` and the
   model's promises section together, and add or change a variant in
   `verification/landing-queue/variants/` that shows the new promise failing when
   its check is removed. A promise with no failing variant is unproven and belongs
   in the report as a gap.
4. Run the check. Every base config `holds` and every variant `breaks` its own
   invariant.
5. Move `Verified at` below to the new commit, and only after steps 1 to 4 pass.

## Coverage record

- Model: `verification/landing-queue/LandingQueue.tla` and its sibling modules,
  configs beside it and in `variants/`.
- Runner: `verification/landing-queue/check.py`.
- Verified at: `91251ca92`. Each action was compared with the code at that commit.
- Last full run: 21 runs, every base config held and every variant broke its named
  invariant, in 2m48s through the slot, with the config rows adding up to about
  168 seconds. States generated
  and distinct: leases
  133,513 and 7,714; merge-one 6,140,551 and 816,762; merge 4,570,849 and 898,400;
  publish-one 6,408,995 and 631,184; publish 2,668,895 and 633,592.

Before advancing `Verified at`, compare every affected action and helper assumption
with the changed code. Update the model to reflect changed guards, outcomes, and
transaction boundaries, then run the check. An unchanged model can pass after a code
regression. If the check fails, work through "Tell a real bug from a modelling
error".

For the reverse case, a change to verified code that never updated the model, see
the `/maintain-formal-verification-skill` skill.

## Known limits

The search is bounded, and a pass covers the modelled actions and nothing else. Git
and GitHub are modelled as external actors, along with the clock and other writers
on trunk, so the model holds only as far as those actors behave as its actions say.
The model drifts unless someone compares it with changed code and updates it.
`Verified at` records that comparison. Rerunning an unchanged model cannot detect
drift by itself.