You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#638 introduces the skeleton (ForkProtocol, ForkRegistry, forks/devnet4/, forks/devnet5/). This issue scopes the follow-up work and lets us check items off as PRs land.
Update (post-merge): the devnet4/devnet5 split documented below was
later unified into a single fork named lstar (#705 body: "devnet5 was
unified back into lstar"). Where the original roadmap references Devnet5Spec / Devnet5State / Devnet5Block, treat it as a stand-in
for "a future fork that introduces real divergence." The Vision A/B
framing below still applies — it documents the design choice that
makes single-attribute swaps work — but the concrete Devnet5* symbols
no longer exist in the codebase. Stage 8 will materialize when an
actual next fork is introduced.
End state
ForkProtocol is fully typed: no Any, every class-pointer bound by a structural protocol.
Spec classes own the logic (state transition, fork choice, block production, signature verification). Containers (State, Block, Store) are pure Pydantic data.
Store is generic over StateT / BlockT; swapping State or Block in a new fork is a typedef, not a copy.
Subspecs (xmss/, validator/, storage/, sync/, chain/, node/) hold no imports from forks.devnetN.*. Fork-specific types reach them through the Spec or via structural protocols.
A new fork can override a single attribute (e.g. block_class = NextForkBlock) on a fork class and inherit 100% of the logic.
Tests live under tests/lean_spec/forks/<fork>/ and can be mirrored per fork cheaply.
Capability protocols (PQCapable, NetworkCapable, etc.) exist and drive test selection via @requires(...).
Design decision: ForkProtocol stays non-generic (Vision B)
Two strategies were considered for the class-pointer fields (state_class, block_class, store_class):
Vision A: Generic protocol
Vision B: Plain narrowable attributes
Shape
class ForkProtocol(ABC, Generic[StateT, BlockT, StoreT])
class ForkProtocol(ABC)
Class-pointer fields
state_class: type[StateT]
state_class: type[StateProto]
Concrete fork
class NextFork(ForkProtocol[State, Block, Store])
class NextFork(ForkProtocol) with state_class: type[State] = State
class NextFork(LstarSpec) swapping a single field
Breaks — generics inherit fixed parameters and can't be re-bound on a concrete subclass
Vision B is the chosen direction. The architecture's central use case is "a future fork inherits the current one's logic and swaps one container type." Vision A makes that impossible. Vision B accepts looser type flow at method call sites in exchange for working subclass-and-narrow inheritance.
Implications:
No Generic[StateT, BlockT, StoreT] on ForkProtocol.
No ClassVar on state_class / block_class / store_class (it triggers pyright's invariance rule and blocks subclass narrowing). ClassVar stays correct for plain constants like NAME, VERSION, GOSSIP_DIGEST.
Method return types in the base ForkProtocol are protocol-typed (StateProto, StoreProto, BlockProto).
Store's genericity (Stage 5: class Store(Generic[StateT, BlockT])) is independent of ForkProtocol's shape.
Delete the generate_pre_state fork fallback in consensus_testing/genesis.py. Require fork everywhere; thread it through the pre pytest fixture. (refactor(forks): tighten ForkProtocol surface (Stage 1 of #686) #694 originally removed the ForkProtocol | None fallback, but the current signature on main is fork: LstarSpec | None = None with a concrete default — the spirit of the cleanup holds but the literal "require fork everywhere" is not enforced.)
Stage 2 — move fork-stable types out of the fork package
Several types in forks/devnet4/containers/ are not genuinely fork-specific and should live in lean_spec.types:
The proposal claimed "subspecs/ contains only fork-agnostic shared libraries." Today most subspecs hard-import from forks.devnet4.containers.*. This stage makes the claim true.
Reference pattern: subspecs/observability/ (#667). Defines a SpecObserver Protocol, exposes a module-level singleton, lets the spec call hooks via context managers. Vendor-specific code lives in a separate adapter (metrics/spec_observer.py). Replicate this shape for the subspecs below.
Stage 4 — move logic from containers to Spec (staged migration)
Container methods (state_transition, process_block, on_block, on_tick, etc.) move from State/Store/SignedBlock to the fork's Spec class. Containers become pure Pydantic data. Inside Spec methods, every literal Block(...) / State(...) / Store(...) becomes self.block_class(...) / self.state_class(...) / self.store_class(...) so inherited methods construct the right fork's containers.
Split into four sub-PRs so each is independently reviewable and revertible:
PR 4C: Move method bodies from containers into Spec; replace literal class references with self.*_class(...). Carry observability hooks (observe_state_transition, observe_on_block, observe_on_attestation) along. (refactor(forks): move verify_signatures body into the spec (Stage 4C, part 1 of #686) #704 — the PR title says "part 1" but its commit chain includes 6d05f1b refactor(forks): move State and Store bodies into the spec class which closes the full scope)
Document Devnet5Store = Store[Devnet5State, Devnet5Block] as the typedef pattern.Dropped — devnet5 was unified back into lstar, so the only concrete binding today is LstarStore = Store[State, Block]. The typedef pattern remains the recommended path for whichever future fork actually diverges. (refactor(forks): make Store generic over StateT, BlockT (Stage 5 of #686) #705)
The test filler already supports fork-range gating via the valid_from / valid_until / valid_at pytest markers (introduced with the skeleton in #638; see packages/testing/src/framework/pytest_plugins/filler.py). These select tests by which fork they apply to. Stage 7 adds an orthogonal axis: selecting tests by which capability the fork advertises (e.g. PQ-signature support, a particular networking transport), letting researchers target experimental forks without naming them in every test.
Define one real capability protocol first (e.g. SigScheme bound to current XMSS). (feat(forks): add SigScheme capability and @requires marker (Stage 7 of #686) #715 — added SigScheme runtime-checkable Protocol in forks/capabilities.py; LstarSpec advertises it via a sig_scheme: ClassVar[GeneralizedXmssScheme] binding; the three spec methods that previously took a scheme= parameter now read self.sig_scheme directly.)
Add a @requires(capability) pytest marker that composes with the existing valid_from / valid_until / valid_at gating. (feat(forks): add SigScheme capability and @requires marker (Stage 7 of #686) #715 — marker registered in the filler plugin; _check_markers_valid_for_fork AND-composes capability checks with the fork-range branches; framework.markers.requires(...) helper bypasses pytest's auto-detect-class shortcut on Protocol args.)
Stage 8 — first real fork divergence
Acceptance test for the architecture. When a new fork actually changes something:
Copy forks/lstar/containers/ → forks/<next>/containers/ and diverge the changed container(s).
Override the diverged *_class attribute on <Next>Spec.
Implement a real upgrade_state migrating lstar state to the next fork's shape.
(Originally drafted for a Devnet5Spec that was later unified back into lstar. The mechanics carry over to whichever fork is introduced next.)
Non-goals
Changing SSZ serialization or hash_tree_root behavior.
Multi-fork architecture: roadmap and tracking issue
Tracks the work to land the multi-fork architecture proposed in
Multi-Fork Architecture for leanSpec.
#638 introduces the skeleton (
ForkProtocol,ForkRegistry,forks/devnet4/,forks/devnet5/). This issue scopes the follow-up work and lets us check items off as PRs land.End state
ForkProtocolis fully typed: noAny, every class-pointer bound by a structural protocol.State,Block,Store) are pure Pydantic data.Storeis generic overStateT/BlockT; swapping State or Block in a new fork is a typedef, not a copy.xmss/,validator/,storage/,sync/,chain/,node/) hold no imports fromforks.devnetN.*. Fork-specific types reach them through the Spec or via structural protocols.block_class = NextForkBlock) on a fork class and inherit 100% of the logic.tests/lean_spec/forks/<fork>/and can be mirrored per fork cheaply.PQCapable,NetworkCapable, etc.) exist and drive test selection via@requires(...).Design decision:
ForkProtocolstays non-generic (Vision B)Two strategies were considered for the class-pointer fields (
state_class,block_class,store_class):class ForkProtocol(ABC, Generic[StateT, BlockT, StoreT])class ForkProtocol(ABC)state_class: type[StateT]state_class: type[StateProto]class NextFork(ForkProtocol[State, Block, Store])class NextFork(ForkProtocol)withstate_class: type[State] = Stateclass NextFork(LstarSpec)swapping a single fieldblock_class: type[NextForkBlock] = NextForkBlock, inherits everything elseVision B is the chosen direction. The architecture's central use case is "a future fork inherits the current one's logic and swaps one container type." Vision A makes that impossible. Vision B accepts looser type flow at method call sites in exchange for working subclass-and-narrow inheritance.
Implications:
Generic[StateT, BlockT, StoreT]onForkProtocol.ClassVaronstate_class/block_class/store_class(it triggers pyright's invariance rule and blocks subclass narrowing).ClassVarstays correct for plain constants likeNAME,VERSION,GOSSIP_DIGEST.ForkProtocolare protocol-typed (StateProto,StoreProto,BlockProto).Store's genericity (Stage 5:class Store(Generic[StateT, BlockT])) is independent ofForkProtocol's shape.Stage 1 — tighten the skeleton
GOSSIP_DIGEST: ClassVar[str]toForkProtocol; route consumers throughfork.GOSSIP_DIGEST. (Stage 1: tighten ForkProtocol surface (trivial items) tcoratger/leanSpec#3; value later changed to a proper 4-byte hex in refactor(forks): rename GOSSIP_DIGEST from devnet0 to 12345678 #700)DEFAULT_REGISTRYin__main__.pyinstead of constructing a secondForkRegistry(FORK_SEQUENCE). (Stage 1: tighten ForkProtocol surface (trivial items) tcoratger/leanSpec#3)devnet5/spec.pyuse direct bindings, not module-level identity aliases. (Stage 1: tighten ForkProtocol surface (trivial items) tcoratger/leanSpec#3) (moot after devnet5 unification into lstar)previous: ClassVar[type[ForkProtocol] | None]linking each fork to its predecessor. (Stage 1: tighten ForkProtocol surface (trivial items) tcoratger/leanSpec#3)SpecRunner→ForkRegistry(it's a registry, not a dispatcher). (Stage 1: tighten ForkProtocol surface (trivial items) tcoratger/leanSpec#3)upgrade_stateabstract; remove the silent identity default. (Stage 1: tighten ForkProtocol surface (trivial items) tcoratger/leanSpec#3)AnyinForkProtocolmethod signatures with fork-stable primitives (Uint64,Bytes32) and structural protocols. (refactor(forks): tighten ForkProtocol surface (Stage 1 of #686) #694)SpecBlockTypeProtocol soblock_classstops being typedtype(i.e.type[Any]). (refactor(forks): tighten ForkProtocol surface (Stage 1 of #686) #694)SpecStateType/SpecStoreType(.slot,.config,.head,.blocks, etc.). (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698 — enriched the protocols with@propertyaccessors alongside the Stage 3 work)ClassVarfromstate_class/block_class/store_class. Stays non-Generic per Vision B. (refactor(forks): tighten ForkProtocol surface (Stage 1 of #686) #694)generate_pre_statefork fallback inconsensus_testing/genesis.py. Require fork everywhere; thread it through theprepytest fixture. (refactor(forks): tighten ForkProtocol surface (Stage 1 of #686) #694 originally removed theForkProtocol | Nonefallback, but the current signature onmainisfork: LstarSpec | None = Nonewith a concrete default — the spirit of the cleanup holds but the literal "require fork everywhere" is not enforced.)Stage 2 — move fork-stable types out of the fork package
Several types in
forks/devnet4/containers/are not genuinely fork-specific and should live inlean_spec.types:Slot. (refactor(forks): move fork-stable types to lean_spec.types (Stage 2 of #686) #695)ValidatorIndexandSubnetId. (refactor(forks): move fork-stable types to lean_spec.types (Stage 2 of #686) #695)Checkpoint. (refactor(forks): move fork-stable types to lean_spec.types (Stage 2 of #686) #695)Config. (refactor(forks): move fork-stable types to lean_spec.types (Stage 2 of #686) #695 — explicitly kept in the fork sinceConfigis the most likely container to grow per fork; the rationale is in the PR body)ForkProtocolsignatures to use these directly. (refactor(forks): move fork-stable types to lean_spec.types (Stage 2 of #686) #695)Stage 3 — decouple subspecs from
forks.devnet4The proposal claimed "subspecs/ contains only fork-agnostic shared libraries." Today most subspecs hard-import from
forks.devnet4.containers.*. This stage makes the claim true.Reference pattern:
subspecs/observability/(#667). Defines aSpecObserverProtocol, exposes a module-level singleton, lets the spec call hooks via context managers. Vendor-specific code lives in a separate adapter (metrics/spec_observer.py). Replicate this shape for the subspecs below.subspecs/xmss/— usesSlot,ValidatorIndex,ValidatorIndices,AggregationBits. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)subspecs/storage/— takestype[State]and storesBlock,Stateby value. Now usesSpec*Typeprotocols and class-pointer injection. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)subspecs/sync/— importsSlot,SignedBlock,Store. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)subspecs/chain/— importsSlot,SignedAttestation. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)subspecs/validator/— importsBlock,BlockSignatures,Slot,ValidatorIndex. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)subspecs/node/node.py— centralize access throughconfig.fork.*_class. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)src/lean_spec/subspecs/imports fromlean_spec.forks.lstar.*. (refactor(forks): decouple subspecs from forks.lstar (Stage 3 of #686) #698)Stage 4 — move logic from containers to Spec (staged migration)
Container methods (
state_transition,process_block,on_block,on_tick, etc.) move fromState/Store/SignedBlockto the fork's Spec class. Containers become pure Pydantic data. Inside Spec methods, every literalBlock(...)/State(...)/Store(...)becomesself.block_class(...)/self.state_class(...)/self.store_class(...)so inherited methods construct the right fork's containers.Split into four sub-PRs so each is independently reviewable and revertible:
self.*_class(...). Carry observability hooks (observe_state_transition,observe_on_block,observe_on_attestation) along. (refactor(forks): move verify_signatures body into the spec (Stage 4C, part 1 of #686) #704 — the PR title says "part 1" but its commit chain includes6d05f1b refactor(forks): move State and Store bodies into the spec classwhich closes the full scope)d505cf5 refactor(forks): delete the trivial container forwarders (Stage 4D of #686), same PR as 4C)Stage 5 — generic containers where it pays
Storegeneric:class Store(StrictBaseModel, Generic[StateT, BlockT]). (refactor(forks): make Store generic over StateT, BlockT (Stage 5 of #686) #705)BlockLookupgeneric soStore[NextForkState, NextForkBlock]composes. (refactor(forks): make Store generic over StateT, BlockT (Stage 5 of #686) #705)DocumentDropped — devnet5 was unified back into lstar, so the only concrete binding today isDevnet5Store = Store[Devnet5State, Devnet5Block]as the typedef pattern.LstarStore = Store[State, Block]. The typedef pattern remains the recommended path for whichever future fork actually diverges. (refactor(forks): make Store generic over StateT, BlockT (Stage 5 of #686) #705)Stage 6 — test reorganization
tests/lean_spec/subspecs/containers/test_state_*.py→tests/lean_spec/forks/lstar/state/. (refactor(tests): move state and forkchoice tests under forks/lstar (Stage 6 of #686) #706)tests/lean_spec/subspecs/forkchoice/*.py→tests/lean_spec/forks/lstar/forkchoice/. (refactor(tests): move state and forkchoice tests under forks/lstar (Stage 6 of #686) #706)Mirror structure for devnet5 (initially empty).Moot — devnet5 no longer exists. Thetests/lean_spec/forks/<fork>/layout is in place and a future fork can be mirrored there cheaply. (refactor(tests): move state and forkchoice tests under forks/lstar (Stage 6 of #686) #706)Stage 7 — capability protocols
The test filler already supports fork-range gating via the
valid_from/valid_until/valid_atpytest markers (introduced with the skeleton in #638; seepackages/testing/src/framework/pytest_plugins/filler.py). These select tests by which fork they apply to. Stage 7 adds an orthogonal axis: selecting tests by which capability the fork advertises (e.g. PQ-signature support, a particular networking transport), letting researchers target experimental forks without naming them in every test.SigSchemebound to current XMSS). (feat(forks): add SigScheme capability and @requires marker (Stage 7 of #686) #715 — addedSigSchemeruntime-checkable Protocol inforks/capabilities.py;LstarSpecadvertises it via asig_scheme: ClassVar[GeneralizedXmssScheme]binding; the three spec methods that previously took ascheme=parameter now readself.sig_schemedirectly.)@requires(capability)pytest marker that composes with the existingvalid_from/valid_until/valid_atgating. (feat(forks): add SigScheme capability and @requires marker (Stage 7 of #686) #715 — marker registered in the filler plugin;_check_markers_valid_for_forkAND-composes capability checks with the fork-range branches;framework.markers.requires(...)helper bypasses pytest's auto-detect-class shortcut on Protocol args.)Stage 8 — first real fork divergence
Acceptance test for the architecture. When a new fork actually changes something:
forks/lstar/containers/→forks/<next>/containers/and diverge the changed container(s).*_classattribute on<Next>Spec.upgrade_statemigrating lstar state to the next fork's shape.(Originally drafted for a
Devnet5Specthat was later unified back into lstar. The mechanics carry over to whichever fork is introduced next.)Non-goals
hash_tree_rootbehavior.State(rejected in feat: add multi-fork architecture with ForkProtocol and SpecRunner #638; copy-then-diverge is the supported pattern).