Skip to main content

verify-candidate-queue

Model-check the canonicalization candidate queue (issue

Quellinformationen

Repository
OpenZeppelin/guardian
Letzte Quellaktivität
23. Oktober 2026 um 06:46
Erkannte Sprache von SKILL.md
Englisch
Sterne
11
Forks
21

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

Datei-Explorer
8 Dateien

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
verify-candidate-queue
description
Model-check the canonicalization candidate queue (issue
# Verify the candidate queue `model/CandidateQueue.tla` is a TLA+ model of one account's candidate queue. TLC explores every interleaving of client pushes (honest nonce labels and timestamp labels), abandon requests, the chain landing acknowledged transactions, the worker's full, fast-promotion and reconcile passes, and a promotion that outlives its pass's lease. One model step is one Postgres transaction, one statement outside a transaction, or one `delta_write_lock` section on the filesystem backend. Every `.await` between two storage calls in the worker is a separate step. ## What is checked | Property | In plain words | Harm if broken | |---|---|---| | `CanonicalIsOnChain` | The stored state is one the chain held, and every `canonical` row is a step the chain took, with a payload that produces its recorded commitment. | `/state` and `get_delta_since` serve a history the chain never had; clients build on it. | | `CanonicalMovesForward` | The stored state only ever advances along the chain. | A rollback: Guardian serves and acks on an older state. | | `NoCompetingCandidates` | No two queued candidates build on the same base. | Guardian holds two acknowledged, conflicting transactions; one can never land. | | `QueuedAccountIsListed` | An account with a queued candidate has `has_pending_candidate = true`, so the full pass visits it. | The account wedges: the full pass never sees it, and at depth 1 every push gets `409 conflict_pending_delta`. | `check.sh` runs the first three together (group `safety`) and the last one alone (group `listed`). Each group runs per backend (`postgres`, `filesystem`) and per queue depth (`MaxPending`). ## Does my change touch this? Yes if the diff changes any function listed in `mirrored.txt`. The `check.sh` drift check hashes each one and lists any that changed, so when unsure, run it. Things the model treats as one step, and that therefore must stay one step in the code: - `submit_candidate`: gate, supersede, insert, and flag set under the account lock (Postgres `lock_account_metadata`, filesystem `delta_write_lock`). - `promote_candidate`: lease fence, exact-kind row gate, base gate (`states.commitment = prev_commitment`), and in-transaction flag clear. - `update_candidate_status` and `discard_candidate`: locked read-check-write. The request-path checks in `services/push_delta.rs` and `services/candidate_chain.rs` are deliberately not modeled, apart from the label rule. The model lets a client build on any commitment it has ever seen, so the storage gate is the only authority. A change that moves a rule from the storage gate into the request path alone is exactly the kind of change this model will flag. ## Run it From the repo root: ```bash .agents/skills/verify-candidate-queue/check.sh # quick tier, about 5 min .agents/skills/verify-candidate-queue/check.sh --deep # adds depth 1, about 8 min ``` A FAIL verdict names the property TLC reports violated (`FAIL:QueuedAccountIsListed`; any temporal property is `FAIL:temporal`), and an expected failure in `expected.txt` must name it too, so a different violation in a cell that is expected to fail still shows as a `DIFF`. `--cell` exits 0 on PASS, 1 on FAIL and 2 on ERROR. `--accept-drift` refuses to record a mirrored function it cannot find (renamed or removed): fix `mirrored.txt` first. Set `TLC_WORKERS` to cap TLC's worker threads (default: all cores). It needs `java` (any recent JDK; `brew install openjdk`) and `python3`. The first run downloads `tla2tools.jar` 1.7.4 to `~/.cache/guardian-tla/` and checks its SHA-256. Each cell prints `ok` or `DIFF` against `expected.txt`. Exit code 0 means every cell matched and nothing drifted. 1 means a cell changed verdict. 3 means the cells matched but mirrored Rust changed (see below). 2 is a setup problem. Today's baseline is not all-PASS: the filesystem `listed` cells fail on finding F2 below, and `expected.txt` records that. To see a full counterexample (every variable in every state): ```bash .agents/skills/verify-candidate-queue/check.sh --cell postgres 2 listed ``` ## Reading a result A `DIFF` from PASS to FAIL prints the counterexample as a list of model actions. Translate it with the action map below, then decide: - **Real bug:** every step is something the code can do, in that order, with those atomicity boundaries. Report it with the trace in code terms. Do not change `expected.txt` or weaken the property to make it pass. - **Modeling error:** some step is impossible in the code, for example the model splits a transaction the code runs in one lock, or lets a worker act without a read the code performs. Fix the model action to match the code, cite the Rust, and rerun. A FAIL turning into PASS on a filesystem `listed` cell means F2 was fixed. Confirm the fix is real, then update `expected.txt` and the findings section here. `ERROR` means TLC could not run the model: a parse error, or an action that leaves a variable unspecified. The first lines of TLC's error are printed. ## When the change is intended 1. Map the changed Rust to its model action (table below) and edit the action so it does what the new code does: same reads, same writes, same lock scope. 2. If the change adds a step (a new storage call or `.await` between two existing ones), add it as a separate action. Don't fold it into a neighbour. 3. Run `check.sh --deep`. A property that now fails is a finding about the change, not a reason to edit the property. 4. Once the model matches the code, run `check.sh --accept-drift` to re-record `fingerprints.txt`. If you add or rename a mirrored function, update `mirrored.txt` first. ## Action map Paths are relative to `crates/server/src/`. | Model action | Code | |---|---| | `Gate`, `Unchained`, `Position` | `storage/mod.rs` `gate_candidate_submission`, `first_unchained_candidate`, `classify_chain_position` | | `ClientBuild`, `ClientLabel` | `services/push_delta.rs` `push_delta` (delta built on some base; label rule behind a queued candidate) | | `ClientSubmit`, `ClientCommit` | `storage/postgres.rs` `submit_candidate` (one transaction; visible at COMMIT) | | `ClientSubmit`, `ClientFsFlag` | `storage/filesystem.rs` `submit_candidate` (row write, then `set_has_pending_candidate`, both under `delta_write_lock`) | | `Abandon` | `storage/*` `request_candidate_abandon` | | `Land` | the chain: only a transaction Guardian acknowledged, only from its base | | `FullList`, `FullPull` | `jobs/canonicalization/processor.rs` `process_full_pass`, `process_account` (incl. stale-flag heal) | | `FullIter`, `FullStored`, `FullPredQ`, `FullNext` | `process_candidate_list`, `predecessor_still_queued` | | `PfcRead`, `PfcObserve` | `process_full_candidate`, `landed_through_successor` (retry, grace and divergence counters abstracted) | | `ParkFlip`, `ParkRefused` | `park_candidate` → `retain_candidate`, `finalize_abandoned_candidate` → `update_candidate_status` | | `RelRead` | `release_account_if_queue_empty` → `storage.has_pending_candidate` | | `ClrPg` | `metadata/postgres.rs` `clear_pending_candidate_if_none` (locks the metadata row, then the conditional UPDATE) | | `ClrFs` | `metadata/mod.rs` default `clear_pending_candidate_if_none` (plain clear) | | `Promote`, `PromoteWrite` | `canonicalize_verified_delta` → `storage/*` `promote_candidate`; the `superseded` arm is `storage/postgres.rs` `restore_superseded` / `storage/filesystem.rs` `restore_superseded_holding_lock` | | `LoseLease`, `ZombieWrite`, `ZombieSeesNewHolder` | `worker.rs` lease loss mid-pass; `storage/postgres.rs` `lease_fence_is_current` after `lock_account_metadata` (ordering proved in `verify-worker-lease`) | | `FastPull` … `FastNext` | `process_recent_pass`, `process_recent_page`, `process_recent_candidate` | | `RcPull` … `RcPathNext`, `SelectPath`, `Walk`, `Try`, `HopOrder` | `jobs/canonicalization/reconciliation.rs` `reconcile_account`, `reconcile_chain_advance`, `select_recoverable_path` (backtracking), `reconcile_recoverable_path`, TTL expiry via `remove_delta`; archived rows come from `pull_superseded_deltas` tagged `superseded` | | `archive`, `RcPrune` | `superseded_deltas` (migration 2026-10-19-000001): `submit_candidate` archives the parked row it supersedes; `prune_superseded_deltas` drops rows past the TTL | ## Scope and assumptions - One account; two concurrent clients; at most 3 deltas built (`MaxDeltas`); queue depth 1 (`--deep`) and 2. A pass covers this bound, not every queue length or client count. - The chain only moves by a transaction this Guardian acknowledged (MultisigGuardian). Guardian switches, `configure_account` and other out-of-band state writes are not modeled. - A delta's post-state commitment is the true result of applying it, so the client's claimed commitment always matches the recomputation. Mismatched claims and RPC errors (the worker defers) are not modeled. - Retry, grace-period and divergence-confirmation counters are abstracted: any observation that could ever lead to parking may park. This over-approximates when parking happens. - Retention is on (`retained_ttl_seconds > 0`), so a supersede archives the replaced row. The retention-off delete paths are not modeled. - One `delta_write_lock` section is one step, including crash points inside it. The filesystem backend is development-only and is not crash-atomic: its restore of an archived delta rewrites the state and delta files before it deletes the archived copy, so a crash or write failure in between leaves that copy behind rather than losing it (a later restore of it is refused because the row is no longer parked, and the prune drops it at the TTL). Postgres does the whole restore in one transaction. - At most one lease holder at a time. A lost lease can leave one promotion in flight (Postgres only), and it may still commit, but only before the new holder's first step that takes the account lock. That ordering is proved separately by `.agents/skills/verify-worker-lease` (`WritesInTokenOrder`); rerun that skill when the lease or the fence changes. - Proposals, `release_on_switch`, metrics and auth refresh are out of scope. Guards the model shows are load-bearing: removing the competing-base refusal, accepting an at-base chain read as landed, skipping the fast pass's chain check, or widening the promote kind gate each breaks a property. Removing the promote base gate alone does not, because the lease fence covers it. Letting a stale promotion land after the new holder's own locked steps does: it can stamp a retained row that was superseded and retained again at the same nonce. ## Findings in the baseline **F1 (fixed). Postgres: the worker's flag clear could wipe a concurrent submission's flag.** `clear_pending_candidate_if_none` used to be a lone `UPDATE ... WHERE has_pending_candidate AND NOT EXISTS (candidate)`. Under READ COMMITTED, when it waited on a submission's row lock it re-checked only that row afterwards, while the `NOT EXISTS` subquery kept its pre-wait snapshot. A clear running during a `submit_candidate` transaction therefore set the flag to `false` with a candidate queued, and the full pass never visited the account again. The fix locks the metadata row first, in one transaction, so the UPDATE's snapshot is taken after any in-flight submission commits. The regression test is `metadata::postgres::tests::clear_pending_candidate_waits_for_an_in_flight_submission` (it fails without the lock). If a change brings back a `postgres listed FAIL`, this race, or one like it, is back. **F2. Filesystem: the plain flag clear can wipe a concurrent submission's flag** (`filesystem * listed FAIL`). It is the same sequence as F1, except that the clear is an unconditional `set_has_pending_candidate(false)` outside `delta_write_lock`. `metadata/mod.rs` documents this as an accepted residual window for single-process backends. It is kept as a finding because the consequence is the same wedge.
Auf GitHub ansehen