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.
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 devnet can override a single attribute (e.g. block_class = Devnet5Block) on a fork class and inherit 100% of the logic.
Tests live under tests/lean_spec/forks/devnetN/ 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 Devnet4Spec(ForkProtocol[State, Block, Store])
class Devnet4Spec(ForkProtocol) with state_class: type[State] = State
class Devnet5Spec(Devnet4Spec) 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 "Devnet5 inherits Devnet4'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.
Drop ClassVar from state_class / block_class / store_class. Stays non-Generic per Vision B.
Delete the generate_pre_state fork fallback in consensus_testing/genesis.py. Require fork everywhere; thread it through the pre pytest fixture.
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:
Move Slot.
Move ValidatorIndex and SubnetId.
Move Checkpoint.
Evaluate Config (likely belongs with subspecs/chain/).
Update ForkProtocol signatures to use these directly.
Stage 3 — decouple subspecs from forks.devnet4
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.
subspecs/node/node.py — centralize access through config.fork.*_class.
CI guard: extend the AST-walk test to assert no file under src/lean_spec/subspecs/ imports from lean_spec.forks.devnetN.*.
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 4A: Add Spec method surface as delegators (no call-site changes; new surface is unused initially).
PR 4B: Rewrite ~300 test call sites to go through Spec methods. Decide test ergonomics (parameter-passing vs. fluent wrapper) up front.
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.
PR 4D: Delete the now-trivial container forwarders.
Stage 5 — generic containers where it pays
Make Store generic: class Store(StrictBaseModel, Generic[StateT, BlockT]).
Make BlockLookup generic so Store[Devnet5State, Devnet5Block] composes.
Document Devnet5Store = Store[Devnet5State, Devnet5Block] as the typedef pattern.
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 = Devnet5Block) on a fork class and inherit 100% of the logic.tests/lean_spec/forks/devnetN/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 Devnet4Spec(ForkProtocol[State, Block, Store])class Devnet4Spec(ForkProtocol)withstate_class: type[State] = Stateclass Devnet5Spec(Devnet4Spec)swapping a single fieldblock_class: type[Devnet5Block] = Devnet5Block, inherits everything elseVision B is the chosen direction. The architecture's central use case is "Devnet5 inherits Devnet4'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)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)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.SpecBlockTypeProtocol soblock_classstops being typedtype(i.e.type[Any]).SpecStateType/SpecStoreType(.slot,.config,.head,.blocks, etc.).ClassVarfromstate_class/block_class/store_class. Stays non-Generic per Vision B.generate_pre_statefork fallback inconsensus_testing/genesis.py. Require fork everywhere; thread it through theprepytest fixture.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.ValidatorIndexandSubnetId.Checkpoint.Config(likely belongs withsubspecs/chain/).ForkProtocolsignatures to use these directly.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.subspecs/storage/— takestype[State]and storesBlock,Stateby value.subspecs/sync/— importsSlot,SignedBlock,Store.subspecs/chain/— importsSlot,SignedAttestation.subspecs/validator/— importsBlock,BlockSignatures,Slot,ValidatorIndex.subspecs/node/node.py— centralize access throughconfig.fork.*_class.src/lean_spec/subspecs/imports fromlean_spec.forks.devnetN.*.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.Stage 5 — generic containers where it pays
Storegeneric:class Store(StrictBaseModel, Generic[StateT, BlockT]).BlockLookupgeneric soStore[Devnet5State, Devnet5Block]composes.Devnet5Store = Store[Devnet5State, Devnet5Block]as the typedef pattern.Stage 6 — test reorganization
tests/lean_spec/subspecs/containers/test_state_*.py→tests/lean_spec/forks/devnet4/state/.tests/lean_spec/subspecs/forkchoice/*.py→tests/lean_spec/forks/devnet4/forkchoice/.Stage 7 — capability protocols
Defer until 2+ forks have real capability divergence — speculative infrastructure otherwise.
SigSchemebound to current XMSS).@requires(capability)pytest marker on top ofvalid_from/valid_until.Stage 8 — first real devnet divergence
Acceptance test for the architecture. When devnet5 actually changes something:
forks/devnet4/containers/→forks/devnet5/containers/and diverge the changed container(s).*_classattribute onDevnet5Spec.upgrade_statemigrating devnet4 state to devnet5 shape.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).