refactor: adopt freenet-migrate for the legacy-contract fallback - #66
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
freenet-stdlib exports __frnt_set_id as an unconditional #[no_mangle] extern "C" symbol, so linking the crate's stdlib 0.8 alongside this workspace's 0.6 is a duplicate-symbol error under rust-lld (CI); the local mold linker tolerated it, which is why the previous commit was green locally. The registry/codegen/guard half (freenet-migrate-build, no stdlib dependency) stays; the probe walk is local again, keeping the newest-generation-first ordering and the #19 abort-on-unknown semantics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
…pty lineage freenet-migrate-build 0.2.0 derives Registry with #[serde(default)] on both fields and no deny_unknown_fields, so a row under the wrong table name deserializes to an EMPTY registry and the build succeeds (freenet/freenet-migrate#20, filed today). The likeliest wrong name is [[entry]] -- the format this very file used before adopting the crate. That is the failure the whole mechanism exists to prevent, arriving through the parser. An empty lineage means the fallback probes no predecessor at all, so a repo published under an older contract WASM is intact on the network and permanently unreachable, with no error at any layer. This repo is unusually exposed. Its registry is legitimately EMPTY during the pre-1.0 phase, so an "is the lineage empty?" canary cannot tell a correct empty file from one full of unparsed rows -- registry.contract .is_empty() is true either way. Checking table names is the only local signal that separates them. The guard runs BEFORE codegen, because the parser cannot report what it silently drops. Verified red-capable rather than assumed: clean build green; append an [[entry]] block and the build fails naming the line and the fix; restore and it is green again. Full suite under lld (what CI uses, not mold) 243 passed / 0 failed. The PR body previously claimed a malformed entry is always a build failure. That was true of hashes and duplicate generations and false of table names; this makes it true as stated. Found by Fable 5 review of #66. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEgtjegwuJPWSaAnVJ3z4e
Fable 5 review — no blocking findings. Both SHOULD-FIXes addressed.Adversarial review at head SHOULD-FIX 1 — unknown tables were silently ignored. FIXED in
|
Problem
freenet-git hand-rolls its legacy-contract migration machinery: a bespoke TOML parser in
build.rs, a bespoke key derivation (contract_id_from_wasm_hash), and a bespoke probe walk inwsclient::get_state_with_legacy_fallback. Four Freenet apps independently wrote this same machinery;freenet-migratenow packages the shared, field-proven version (River's shipped UI probe, adopted by Delta and Atlas). The registry here is still empty — deliberately, per the pre-1.0 churn note inlegacy_contracts.toml— which is exactly why now is the right moment to switch: the first real re-key (already queued as #63) should run on the shared path, not on bespoke code that has never once executed against real data. #65 (merged) gave the bespoke path a behavioural test suite for the same reason; those tests are the spec this PR is held to.No user data is affected by this change: the registry has zero entries, so the fallback has never fired in any shipped binary, and the probe behaviour for an empty registry is unchanged (one GET at the current key).
What is adopted
legacy_contracts.tomlmoves to freenet-migrate's[[contract]]schema (explicitgeneration,code_hashin hex or base58,note), parsed and validated at build time byfreenet-migrate-build. A typo'd hash, duplicate generation, or malformed row is now a build failure; the retired hand-rolled parser silently skipped unrecognized keys and misspelled[[entry]]blocks. The file's explanatory comments (the operator-facing docs) are kept and updated.contract_id_from_wasm_hash, because the crate's equivalent lives in the runtime half (see the constraint below).tests/migration_guard.rswiresfreenet-migrate-build'scheck_migration_guardto the committedcontracts/repo-contract.wasm: swapping the WASM without registering the outgoing hash now fails the suite with step-by-step instructions. This closes part of the CI gap Rebuild repo-contract.wasm to ship the get_state_delta absent-vs-zero fix (requires re-key + migration) #63 calls out (CI still doesn't rebuild the WASM from source; the guard covers artifact swaps, which is the re-key vector).The constraint that shaped this PR: only the build half is adoptable here
An earlier revision of this PR adopted the runtime half of
freenet-migrateas well. Itcannot be adopted in this repo, and the reason is structural rather than a bug to work around.
freenet-stdlib/rust/src/global.rs:4-5exports__frnt_set_idas#[no_mangle] extern "C"withno
cfg(feature)guard, so every build of the crate emits that symbol. Linking two versionsinto one binary is therefore always:
freenet-git's contracts are pinned to stdlib 0.6, and bumping them re-keys every repo — which
is itself the migration event this machinery exists to survive.
freenet-migrate0.5.x requires0.8. So the runtime half is unavailable until the contracts move, and that move is its own
deliberate migration, not a dependency bump.
freenet-migrate-buildis unaffected and is what this PR adopts: its dependencies are serde,toml, blake3 and bs58 — no stdlib at all — so it can never reintroduce the collision.
The local test suite could not have caught this
The first revision reported 243/243 green locally and failed at link in CI. This repo links
with mold; CI uses rust-lld. The local suite was structurally incapable of producing the
failure that gates merge. Reproduce CI's behaviour with:
Verified on this head: 243 passed, 0 failed, single
freenet-stdlib v0.6.1in the tree.The bug this actually fixes
The old walk took candidates in registry slice order (oldest first) and stopped at the first
hit. With two surviving generations that means the stale one wins — and is then re-PUT forward
onto the current key, promoting old state over new. It has never harmed anyone only because the
registry is empty.
The walk is now newest-generation-first, ordered by the explicit
generationfield rather thanby position in the file, so re-ordering the TOML cannot change behaviour. Both properties are
pinned: newest-first ordering, and that a stale older survivor loses.
Behavioural spec of the old code, and every difference accepted
Spec derived from the code + #65's tests before touching anything:
NotFoundand empty responses are authoritative per-key absence; timeouts/backpressure are transient; dead sockets are hard errors.GetSource::Legacy { index }indexes the registry slice for the log line.Differences deliberately accepted (each is a behaviour change only for a populated registry, i.e. live for no one today):
generationdescending)Everything else is preserved: the retry classifier (
is_transient_host_error,ProbeOutcome,outcomes_worth_retrying), the 4-attempt budget, the backoff schedule, the dominant-outcome error messages, the empty-vs-NotFound distinction, the forward re-PUT with warning-on-reject, and the slice-index contract ofGetSource::Legacy.Explicit override of a crate default: the driver's recommended semantics treat a timeout as a per-candidate miss ("the predecessor does not have it"). That is the data-loss default tracked as freenet-migrate#19, and both prior adopters overrode it. Here the pump delivers only authoritative absence as a miss; unknown outcomes abort the pass for retry (difference 2 above makes this stricter than the old code, not just equal).
The stdlib seam — why only the build half
This workspace pins freenet-stdlib 0.6, including the contract crates, and
freenet-migrate'sruntime half is built against 0.8. An earlier revision of this PR carried both. It cannot
work:
freenet-stdlib/rust/src/global.rs:4-5exports__frnt_set_idas#[no_mangle] extern "C"with no
cfg(feature)guard, so two versions in one binary is alwaysrust-lld: error: duplicate symbol: __frnt_set_id. There is no feature flag that avoids it.Bumping the contracts to 0.8 would re-key every repo — its own migration event, not a dependency
bump — so the runtime half is unavailable here until that happens. This PR therefore adopts only
freenet-migrate-build, whose dependencies are serde, toml, blake3 and bs58 (no stdlib at all),and keeps the walk and the key derivation local.
No contract WASM changed
crates/freenet-git/contracts/repo-contract.wasmandpack-contract.wasmare byte-identical to main (b3sum:a397cc99…9c13,4140e677…0a87); no file undercrates/repo-contract/,crates/pack-contract/, orcrates/types/is touched. The new dependencies land only in the CLI crate, so no contract re-key is implied by this PR. (Workspacerust-versionmoves 1.86 → 1.87 for freenet-migrate's MSRV; the build toolchain is pinned at 1.94.1 and the committed WASM artifacts are, per the above, unchanged.)Testing
Full workspace suite: 237 passed / 0 failed before (at
b3b1451), 243 passed / 0 failed after.cargo fmt --checkandcargo clippy --workspace --all-targetsclean.The #65 tests are preserved as the behavioural spec, with three adjustments, none deleted quietly:
walks_predecessors_in_registry_order_and_reports_the_index→walks_predecessors_newest_generation_first: the ordering it pinned (slice order) is the latent-rollback behaviour difference 1 removes. The replacement pins the new ordering and that a stale older survivor loses to the newest generation, plus that the reported index stays a slice index across the reordering.legacy_registry_codegen.rsis rewritten againstfreenet-migrate-build(the hand-rolled parser it tested is deleted). Same properties, same oracle independence: TOML → generated text → probeable key, compared against the 0.6 stdlib's own derivation; loud failure on missing hash / truncated hash; NEW: duplicate generations rejected (the old parser accepted them silently). The empty-registry canary now asserts on the compiledCONTRACT_LINEAGEconst, and still fires (with update-don't-delete instructions) the day the first real entry lands.production_fetch_repo_state_passes_the_generated_registryis retargeted atCONTRACT_LINEAGE, guards intact.New tests:
an_unreachable_newer_generation_is_never_skipped_for_an_older_one(the #19 override, end to end),persistent_transient_failure_exhausts_the_retry_budget(bounded retries; timeouts reported as timeout, never absence; deliberately ~17s of real time — the budget under test is wall-clock), and the twomigration_guardratchet tests.Mutation evidence
Every mutation was applied to production code (or the guard's pin), the suite run with
--no-fail-fastover the freenet-git targets (165 tests at this head; the table below was first run on the pre-rework revision and re-verified on b71d489 — see the review), and reverted; predictions written down first.fetch_repo_statewrapper passes&[]instead ofCONTRACT_LINEAGE(registry effectively deleted from production wiring)production_fetch_repo_state_passes_the_generated_registrywalks_predecessors_newest_generation_first,an_unreachable_newer_generation_is_never_skipped_for_an_older_one,empty_state_at_a_predecessor_is_not_mistaken_for_recovery,migration_tests::a_second_registry_entry_reports_the_right_indexif authoritative→if true) — the #19 class at the pumpan_unreachable_newer_generation_is_never_skipped_for_an_older_oneTimeout => Transient→Timeout => Authoritative) — the #19 class at the classifieroutcomes_worth_retrying_*unit pins +transient_predecessor_timeout_is_retried_and_then_recovers,an_unreachable_newer…,persistent_transient_failure_exhausts_the_retry_budgetattempt >= PROBE_MAX_ATTEMPTS ||deleted → retry forever)persistent_transient_failure_exhausts_the_retry_budget(fails fast via its own 60s bound instead of hanging)&lineage[..0]handed to the driver — registry entries effectively deleted at the walk)migration_testsmigration_guardratchet testsEvery mutation was verified applied (non-empty diff) before the run and reverted to a clean tree after; predictions were written into the harness before any run. M3a is the layered-defence case: the outer retry masks the pump-level bug everywhere except the adopt-older-past-unknown path, which is exactly why
an_unreachable_newer_generation_is_never_skipped_for_an_older_oneexists as a separate end-to-end pin.Review notes
wsclient::probe_all_keys— it is where the crate's decisions and the freenet-git-specific retry semantics meet, and difference 2 lives there.docs/and Rebuild repo-contract.wasm to ship the get_state_delta absent-vs-zero fix (requires re-key + migration) #63's procedure text still describe the old[[entry]]format only vialegacy_contracts.toml, which now documents the new schema inline (including the guard-pin update step).Closes nothing; groundwork for #63. Refs freenet/freenet-core#2776, freenet/freenet-migrate#19.
[AI-assisted - Claude]