Skip to content

Multi-fork architecture: roadmap and tracking issue #686

@leolara

Description

@leolara

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.

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 Works — NextFork overrides block_class: type[NextForkBlock] = NextForkBlock, inherits everything else

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.

Stage 1 — tighten the skeleton

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:

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.

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:

Stage 5 — generic containers where it pays

Stage 6 — test reorganization

Stage 7 — capability protocols

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions