Spun out of S01-Issuer/st0x.deploy#256, which works the problem through in detail against a live deployment. The mechanism it needs is not st0x-specific, so filing it here — this repo already owns the deploy side (Zoltu deployment, per-network dispatch, deploy-block discovery, dependency codehash checks) and every consumer that runs operational scripts has the same gap.
The problem, in general form
Prod-state tests decide what to assert by reading the clock, not by reading what happened on-chain.
The pattern that emerges without a registry is a dual-state invariant: accept either the pre-migration or the post-migration value until a hardcoded timestamp, and only the post value after it. st0x.deploy has three such deadline constants today. Three consequences, all bad:
- The window asserts nothing. Between deploy and deadline the test passes whether or not the migration ran — the one period where you most want to know is the one you do not check.
- Every migration costs a manual refactor. Author the accept-both branch, pick a deadline, add a
*PostSwap test file, then come back after the date and hand-delete the pre branch.
- The deadline is an operator SLA pretending to be a test. If the operator is late, CI red-lines on a date rather than on a fact. Wall-clock time is not the state under test.
The invariant actually wanted is not "one of two values, depending on the date". It is "exactly the value implied by the migrations that have run".
What this repo could provide
Treat deploy scripts as database migrations, with the chain holding the migration log.
- A registry contract, deterministically addressed per network (Zoltu, which this library already wraps), recording the high-water mark of applied migrations.
- A lib surface alongside
LibRainDeploy for advancing the mark on broadcast and reading it in tests.
- Structural run-once: a migration consults the registry and refuses to re-run, rather than a comment in a workflow dropdown asking a human not to re-dispatch it.
- Monotonic script naming (
YYYYMMDD-<name>.s.sol) giving a total order, so the mark is a single comparable value.
Tests then read lastApplied >= N → assert post-state, else assert pre-state, with an exact assertion in both branches. No window, no deadline, no post-hoc edit.
Constraints worth carrying over from #256
- The registry is an index, not proof. A Safe can act out-of-band — a beacon gets upgraded manually and the mark never moves — so tests would then confidently assert the wrong state. Keep both layers with distinct jobs: the registry selects which invariant applies; codehash/bytecode pins verify it actually holds. Replacing the pins with the registry would trade a clock-guess for a bookkeeping-guess.
- Registry writes must be privileged, or tests trust something anyone can write — which is worse than no registry.
- Tests must never self-skip. rainix's
no-ignored-tests gate bans vm.skip outright. "Assert the invariant that applies to the state you find" is strictly stronger than a skip, because neither branch is ever left unasserted.
Why here rather than per-consumer
Every repo doing this independently re-derives the same three decisions — addressing, access control, and the index-vs-truth split — and gets a different answer. #256 is one consumer's version; the shape is the same wherever operational scripts mutate deployed state.
Open questions (keyed by name/date or sequence number, what a zero-migration network asserts, backfilling networks that are already mid-flight) are enumerated in #256 and apply unchanged.
Spun out of S01-Issuer/st0x.deploy#256, which works the problem through in detail against a live deployment. The mechanism it needs is not st0x-specific, so filing it here — this repo already owns the deploy side (Zoltu deployment, per-network dispatch, deploy-block discovery, dependency codehash checks) and every consumer that runs operational scripts has the same gap.
The problem, in general form
Prod-state tests decide what to assert by reading the clock, not by reading what happened on-chain.
The pattern that emerges without a registry is a dual-state invariant: accept either the pre-migration or the post-migration value until a hardcoded timestamp, and only the post value after it. st0x.deploy has three such deadline constants today. Three consequences, all bad:
*PostSwaptest file, then come back after the date and hand-delete the pre branch.The invariant actually wanted is not "one of two values, depending on the date". It is "exactly the value implied by the migrations that have run".
What this repo could provide
Treat deploy scripts as database migrations, with the chain holding the migration log.
LibRainDeployfor advancing the mark on broadcast and reading it in tests.YYYYMMDD-<name>.s.sol) giving a total order, so the mark is a single comparable value.Tests then read
lastApplied >= N → assert post-state, else assert pre-state, with an exact assertion in both branches. No window, no deadline, no post-hoc edit.Constraints worth carrying over from #256
no-ignored-testsgate bansvm.skipoutright. "Assert the invariant that applies to the state you find" is strictly stronger than a skip, because neither branch is ever left unasserted.Why here rather than per-consumer
Every repo doing this independently re-derives the same three decisions — addressing, access control, and the index-vs-truth split — and gets a different answer. #256 is one consumer's version; the shape is the same wherever operational scripts mutate deployed state.
Open questions (keyed by name/date or sequence number, what a zero-migration network asserts, backfilling networks that are already mid-flight) are enumerated in #256 and apply unchanged.