Skip to content

Migrate to the rolling-candidate snapshot model - #5

Open
thedavidmeister wants to merge 1 commit into
mainfrom
2026-07-25-rolling-candidate-model
Open

Migrate to the rolling-candidate snapshot model#5
thedavidmeister wants to merge 1 commit into
mainfrom
2026-07-25-rolling-candidate-model

Conversation

@thedavidmeister

Copy link
Copy Markdown
Contributor

Migrates this repo to the rolling-candidate model that
S01-Issuer/st0x.deploy#283
adopted, mirroring it rather than inventing a variant. Adapted to this repo's
shape: one contract, one generated pin lib, no multi-contract alias machinery.

What changes

src/generated/candidate/ is new and rolling. script/BuildPointers.sol
now writes it unconditionally — deployTag() went from
vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version") to a pure
function returning the constant "candidate". It is regenerated in full on every
run and committed, so it always describes what the current source compiles to.

src/lib/LibCloneFactoryDeploy.sol aliases candidate/ instead of 0_1_5/.
This is a semantic no-op today: candidate/CloneFactory.pointers.sol is
byte-identical to 0_1_5/CloneFactory.pointers.sol (diff is empty), because
0_1_5 is what the current source compiles to. Same address
0x7053c1D000192f1FeC1fbe20350139F3300dBBaD, same codehash. What changes is the
rule: the consumer-facing pin now tracks the source instead of tracking a
version number.

script/cut-release.sh is new — the direct analogue of st0x's. It cp -rs
candidate to src/generated/<tag>/, refusing on a non-X.Y.Z version (the
append-only gate's tag filter is numeric-only, so a 0_1_7-rc1 dir would be an
orphan snapshot nothing protects) and refusing to overwrite an existing tag dir.
package-release.yaml's snapshot-generate-cmd becomes
bash script/cut-release.sh; the rest of the release wiring was already correct
(on: push: tags: sol-v*rainix-tag-release) and is untouched.

testCandidateSelfConsistent replaces the version↔snapshot coupling. New
test/src/lib/LibCloneFactoryDeployCandidate.t.sol pins the whole chain:

current source -> candidate CREATION_CODE -> candidate DEPLOYED_ADDRESS
               -> LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS

[package].version is no longer read by any Solidity in this repo, so the
{ access = "read", path = "./foundry.toml" } fs permission is dropped too.

Frozen snapshots: all three kept

0_1_5 MUST survive and does. It is the repo's headline pin, live on all
five supported chains, and the only implementation the current source can build
(ICloneableFactoryV3cloneDeterministic, keccak256(abi.encode(deployer, salt)) namespacing, 5-arg NewClone). It is unaudited, and st0x#283's "keep
only the audited snapshot" move would have deleted it. Deleting it would delete
the reason this repo exists. It stays, unaudited, on purpose.

0_1_3/0_1_4 also stay. They hold the Protofire-audited
ICloneableFactoryV2 bytecode (0x444acC29d63fa643E8adCC35FD9aa6DE111dCb39),
which is also live on all five chains and is the pin S01-Issuer/st0x.deploy
consumes in production (via the rain-factory 0.1.1 Soldeer package).

So this migration touches nothing under src/generated/0_1_*. Simulating the
gate's own diff against main:

$ git diff --no-renames --name-status --diff-filter=MD main...HEAD -- src/generated
(empty)

candidate is invisible to the gate by construction —
rainix-static/src/frozen_snapshots.rs:19-25 is_tag() requires exactly three
_-separated all-numeric parts, and "candidate".split('_') has one. So it can
roll forever without any rainix change. candidate/ was generated fresh, never
git mv'd from a numbered dir (the gate passes --no-renames, so a move would
have been recorded as a deletion and flagged).

0_1_4 is a byte-identical duplicate — and I am leaving it

0_1_3 and 0_1_4 are the same file (sha256
fa92b0caa1084172240189245d1f6f7f018a56bec1f2c3d884ffe0ee358d225a for both) —
one bytecode snapshotted twice by autopublish version bumps, exactly the noise
#283 deleted from st0x. Dropping it would break nothing functional: no Soldeer
consumer exists (this repo has zero published revisions), no git tag names
it, its address is identical to 0_1_3's so no deployment record would be lost,
and the only code referencing it is its own test pair in
LibCloneFactoryDeployTaggedConstants.t.sol.

But the frozen-snapshots-append-only gate would block it.
parse_offenders flags status D as well as M, so deleting
src/generated/0_1_4/CloneFactory.pointers.sol produces an offender and turns
rainix-sol / static red. st0x#283 got past exactly this by being merged
red
under a one-off human sign-off. I am not doing that here: it is a separate
decision from the model migration, and it would trade a green PR for a cosmetic
cleanup. 0_1_4 stays. If you want it collapsed, that is its own
deliberately-red PR with its own sign-off.

Supersedes #4

#4 (17b69fb,
"Enforce that [package].version, its snapshot and the deploy lib agree")
implements the model this PR removes. It should be closed when this merges.
Its three assertions, disposed of individually:

  1. testDeployTagDEPLOY_TAG == foundry.toml [package].version. Dropped,
    not folded.
    The candidate model deliberately abolishes that invariant:
    DEPLOY_TAG is now the literal "candidate" and the version names nothing on
    disk, so the assertion would be false by design. This is also the weakness
    that motivates the change — it compares a name to a name, so editing
    CloneFactory without regenerating leaves it green while the pins silently
    misdescribe the source.
  2. testDeployTagSnapshotExistsfolded as a compile-time guarantee. The
    candidate test imports
    ../../../src/generated/candidate/CloneFactory.pointers.sol directly, so a
    missing snapshot is a compile error, not a runtime assertion. Enforce that [package].version, its snapshot and the deploy lib agree #4 needed
    vm.exists only because its DEPLOY_TAG string was decoupled from any
    import.
  3. testDeployTagSnapshotHoldsTheAliasedPinsfolded, and strengthened.
    Enforce that [package].version, its snapshot and the deploy lib agree #4 grepped the snapshot's file text for the rendered literals. The
    equivalent here is testCandidateIsTheAliasedSnapshot, which asserts the
    identity in Solidity against the imported constants — no file parsing — and it
    catches the real hazard Enforce that [package].version, its snapshot and the deploy lib agree #4 was aiming at: the generated lib left pointing at a
    frozen dir while candidate moves on. Mutation-proven below.

The DEPLOY_TAG constant itself — #4's genuinely good idea, making the aliased
snapshot readable without reading an import path — is kept, generated as
"candidate".

Mutation proof

A self-consistency test nobody has seen fail is worthless, so both directions
were broken and restored.

Mutant 1 — candidate disagrees with current source. One nibble flipped in
candidate/CloneFactory.pointers.sol line 21 col 300
(…90f35b61004b6100……90f35b61f04b6100…), i.e. the committed pin no longer
records what the source compiles to:

Ran 4 tests for .../LibCloneFactoryDeployCandidate.t.sol:LibCloneFactoryDeployCandidateTest
[FAIL: assertion failed: 0x872733555ced48eF5766e9e0601fE65FE6EfD58E != 0x7053c1D000192f1FeC1fbe20350139F3300dBBaD] testCandidateCreationDeploysToPinnedAddress()
[PASS] testCandidateIsTheAliasedSnapshot()
[PASS] testCandidateRuntimeHashesToBytecodeHash()
[FAIL: assertion failed: 0x6080…61f04b6100823660046104…56 != 0x6080…61004b6100823660046104…56] testCandidateSelfConsistent()
Suite result: FAILED. 2 passed; 2 failed; 0 skipped

Mutant 2 — generated lib left pointing at a frozen dir. Import repointed from
../generated/candidate/ to ../generated/0_1_3/ — the stale-headline-pin
scenario #4's third assertion was written for:

Ran 4 tests for .../LibCloneFactoryDeployCandidate.t.sol:LibCloneFactoryDeployCandidateTest
[PASS] testCandidateCreationDeploysToPinnedAddress()
[FAIL: assertion failed: 0x444acC29d63fa643E8adCC35FD9aa6DE111dCb39 != 0x7053c1D000192f1FeC1fbe20350139F3300dBBaD] testCandidateIsTheAliasedSnapshot()
[PASS] testCandidateRuntimeHashesToBytecodeHash()
[PASS] testCandidateSelfConsistent()
Suite result: FAILED. 3 passed; 1 failed; 0 skipped

Both restored with git checkout -- <file>, green again:

Ran 4 tests for .../LibCloneFactoryDeployCandidate.t.sol:LibCloneFactoryDeployCandidateTest
[PASS] testCandidateCreationDeploysToPinnedAddress() (gas: 316633)
[PASS] testCandidateIsTheAliasedSnapshot() (gas: 3708)
[PASS] testCandidateRuntimeHashesToBytecodeHash() (gas: 879)
[PASS] testCandidateSelfConsistent() (gas: 4706)
Suite result: ok. 4 passed; 0 failed; 0 skipped

Generator is reproducible

From the committed tree, regenerating produces no diff:

$ forge script ./script/BuildPointers.sol && forge fmt
$ git status --porcelain
(empty)

Docs

README.md said [package].version and the snapshot "only ever move together"
and implied a release history. Neither was true — Soldeer has zero
rain-factory-deploy revisions; nothing has ever been released from this repo.

(sol-v0.1.6 exists as a tag but its run died before publishing anything.)
README and CLAUDE.md now describe the candidate model, and foundry.toml
carries a note that version = "0.1.5" coincides with src/generated/0_1_5/ by
history, not by rule.

I deliberately did not bump the version: which number the first real release
carries is yours to pick, and it is worth knowing sol-v0.1.6 is already taken
as a tag even though it published nothing.

Open ruling for you — not decided here

st0x#283 could delete every unaudited snapshot because there audited == live ==
depended-on. Here they diverge:

pin audited live on 5 chains consumed in prod
0x444acC29… (0_1_3/0_1_4, V2) yes (Protofire, 1a92a86 + v0.1.1) yes yes (st0x.deploy, via rain-factory 0.1.1)
0x7053c1D0… (0_1_5, V3) no yes no

The repo's headline pin and only buildable implementation is unaudited, and the
audited implementation is not in the current source at all. Whether to audit V3
or revert to V2 is your call and this PR deliberately does not touch it
— which
is a further argument for the add-only shape: the migration is orthogonal to that
ruling and pre-empts it in neither direction.

One adjacent fact worth seeing: the split is incomplete. rain.factory main
still carries the entire deploy half (src/concrete/CloneFactory.sol,
src/generated/0_1_3|0_1_4|0_1_5, src/lib/LibCloneFactoryDeploy.sol,
script/BuildPointers.sol, script/Deploy.sol and all four test files), so both
pins currently have two owners.

Behaviour change to be aware of

Because the pin lib now tracks candidate, any change to CloneFactory's
bytecode makes the five LibCloneFactoryDeployProdTest fork tests red until that
bytecode is deployed. Previously they asserted "prod matches 0_1_5"; now they
assert "prod matches HEAD". That is the deploy-before-merge coupling made
explicit, and it is documented in CLAUDE.md.

Verification

forge build, forge fmt --check and reuse lint all pass. forge test is
24/24 green locally, including all five fork tests run against public RPC
endpoints — which also confirms candidate (== 0_1_5) matches prod on every
supported chain.

In CI expect exactly one red: testProdDeployArbitrum, failing with
lb.drpc.live … error code -32001 — an exhausted drpc quota on the
RPC_URL_ARBITRUM_FORK secret. It is pre-existing on main and unrelated to
this change; Arbitrum is confirmed green on chain by direct cast code probe, so
the red is purely the RPC. rainix-sol / static should be green — this PR
adds only, so the append-only gate has nothing to flag.

🤖 Generated with Claude Code

BuildPointers now writes src/generated/candidate/ unconditionally instead of a
dir named after [package].version, and LibCloneFactoryDeploy aliases it, so the
pins consumers import always describe the current source. script/cut-release.sh
freezes candidate as src/generated/<tag>/ at release time and is the only thing
that creates a numbered snapshot. LibCloneFactoryDeployCandidateTest ties the
candidate snapshot to the source, to its own recorded address, and to the lib.

The three existing frozen snapshots are untouched: 0_1_5 is the live headline
pin and 0_1_3/0_1_4 hold the audited bytecode, all live on all five chains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: afbab0ee-b2e4-40e1-af64-710f3dedd527

📥 Commits

Reviewing files that changed from the base of the PR and between 6173d3e and 5640894.

⛔ Files ignored due to path filters (1)
  • src/generated/candidate/CloneFactory.pointers.sol is excluded by !**/generated/**
📒 Files selected for processing (10)
  • .gas-snapshot
  • .github/workflows/package-release.yaml
  • CLAUDE.md
  • README.md
  • foundry.toml
  • script/BuildPointers.sol
  • script/cut-release.sh
  • src/lib/LibCloneFactoryDeploy.sol
  • test/src/lib/LibCloneFactoryDeployCandidate.t.sol
  • test/src/lib/LibCloneFactoryDeployTaggedConstants.t.sol
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-07-25-rolling-candidate-model

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thedavidmeister

Copy link
Copy Markdown
Contributor Author

CI landed exactly as predicted at 5640894.

  • rainix-sol / staticpass. The frozen-snapshots-append-only gate had nothing to flag: this PR adds src/generated/candidate/ and touches no 0_1_* dir.
  • rainix-sol / legalpass.
  • rainix-sol / test23 passed, 1 failed (24 total), the one red being the known pre-existing drpc quota:
Ran 4 tests for test/src/lib/LibCloneFactoryDeployCandidate.t.sol:LibCloneFactoryDeployCandidateTest
Suite result: ok. 4 passed; 0 failed; 0 skipped

Ran 5 tests for test/src/lib/LibCloneFactoryDeployProd.t.sol:LibCloneFactoryDeployProdTest
[FAIL: vm.createSelectFork: could not instantiate forked environment with provider lb.drpc.live; server returned an error response: error code -32001: You've reached the usage limit for your current plan...] testProdDeployArbitrum() (gas: 3212)
Suite result: FAILED. 4 passed; 1 failed; 0 skipped

Ran 5 test suites: 23 tests passed, 1 failed, 0 skipped (24 total tests)

That is an RPC quota on the RPC_URL_ARBITRUM_FORK secret, not a pin mismatch — Arbitrum is confirmed live at the pinned address by direct cast code probe, and locally the same suite is 24/24 green against public endpoints. The other four fork tests (Base, Base Sepolia, Flare, Polygon) pass in CI, which is itself confirmation that candidate matches production.

@thedavidmeister thedavidmeister added the ai:needs-work AI vetter: needs rework (code issue) label Jul 28, 2026
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

🤖 ai:vetter
Reviewed 5640894: reject — Missing QA evidence block (QA-GUIDE §8) — no ## QA section in body or trusted producer comment. Substance audits sound (candidate==0_1_5 verified, mutation-proven test chain, frozen tags untouched); note for the human ruling: cut-release.sh hand-rolls the freeze in untested bash vs the shared LibSnapshot mechanism (st0x#283-conformant). Supersedes #4 — do not land both.
cost 638 — release-model migration, freeze-path bash

@thedavidmeister thedavidmeister added ai:blocked-on AI producer: blocked on a dependency PR and removed ai:needs-work AI vetter: needs rework (code issue) labels Jul 28, 2026
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

🤖 ai:producer
Blocked-on: Blocked on rainlanguage/rainix#289 (fork-RPC failover preflight), which is now ai:design pending a human ruling. The single failing test is testProdDeployArbitrum and it fails entirely upstream of this diff: vm.createSelectFork could not instantiate a forked environment because drpc returned error code -32001, You've reached the usage limit for your current plan. 23 of 24 tests pass. Foundry maps one rpc_endpoints alias to exactly one URL and --fork-retries only re-hits that same dead URL, so no code change in this repo can route around an exhausted vendor quota. Goes green once the failover lands on rainix main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:blocked-on AI producer: blocked on a dependency PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant