Skip to content

feat!: spec-versioned ASM VK upgrades with worker-side activation discovery - #204

Open
prajwolrg wants to merge 8 commits into
asm-upgradabilityfrom
upgradability-spec-activation
Open

feat!: spec-versioned ASM VK upgrades with worker-side activation discovery#204
prajwolrg wants to merge 8 commits into
asm-upgradabilityfrom
upgradability-spec-activation

Conversation

@prajwolrg

@prajwolrg prajwolrg commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Description

Reworks #188 on top of the spec-versioning model that #202 merged into asm-upgradability, superseding the fork-based design with a clean history. The old PR's unstake gate is dropped entirely — nothing consumes the activation schedule yet (threading it into the STF hooks belongs to the AsmSpec pipeline follow-up) — so this PR carries only the upgrade machinery itself: how a spec activation gets on the chain, and how the worker learns about it.

SpecId is reworked so the genesis rules are a version of their own: V0 is always active since genesis and V1 is the first on-chain upgrade. The schedule (SpecSchedule) encodes those rules structurally rather than as one Option field per version: it stores the activation heights of a contiguous run of successors with V0 implicit, so a gapped or v0-disabled schedule is unrepresentable and adding a SpecId variant is a one-line change — the primitive conversions are derived (num_enum) and every schedule operation works off the discriminant, so there is no per-version code to forget. The raw identity narrows to u16 only, and the serialized form keeps the per-version map ({"v0": 0, "v1": null}) while re-validating the invariants on load, so a hand-edited params file fails fast.

Nothing on the wire names the version: the ASM VK upgrade action (and the AsmStfUpdate log it emits on enactment) carries only the new predicate, because the upgrade that activates a version may be enacted by an artifact that predates it. The chain is still the single authority on which version an upgrade activates — the activating version is defined as the successor of the newest scheduled version (SpecSchedule::schedule_successor), chaining through multiple upgrades in one block. Consequence worth flagging: every enactment activates the next version, so a pure VK rotation on the current spec is deliberately not expressible for now.

The worker discovers activations from those logs against its effective schedule (base from runtime params, a new required builder input, overlaid with prior discoveries). The discovered-activation record type (SpecActivationRecord) lives in the worker crate, not strata-asm-common: it is worker bookkeeping rather than protocol surface (this also keeps strata-predicate out of common, and thus out of the guest's common deps), and the sled store persists raw parts with the raw version id mapped back through SpecId only at the worker boundary. A successor this binary has no variant for halts the worker without committing the block — it is running old software past an upgrade it cannot execute. Replaying persisted activations onto the base schedule is validated the same way: a record the schedule cannot fit (e.g. params downgraded below activations already committed) fails startup instead of silently running on a gapped schedule.

The subtle part is crash/reorg safety, carried entirely by ordering: the activation record is persisted (sled, keyed by enacting height and version) before the enacting block's anchor commit, so a committed anchor can never lack its activation; every sync rebase prunes activations above the base before re-processing, so a reorged-out enactment cannot leak into the new branch — its blocks re-discover whatever survives. The activation height is derived, not stored: always the block after the enacting one.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature/Enhancement (non-breaking change which adds functionality or enhances an existing one)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactor
  • New or updated tests
  • Dependency update
  • Security fix

Notes to Reviewers

Review entry point: SpecSchedule and its invariants in crates/params/src/runtime.rs, then discover_spec_activations / apply_spec_activations in crates/worker/src/state.rs.

Checklist

  • I have performed a self-review of my code.
  • I have commented my code where necessary.
  • I have updated the documentation if needed.
  • My changes do not introduce new warnings.
  • I have added tests that prove my changes are effective or that my feature works.
  • New and existing tests pass with my changes.

Related Issues

Part of fork-based ASM upgradeability (#183). Supersedes #188.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.13150% with 58 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
bin/asm-runner/src/worker_context.rs 48.38% 16 Missing ⚠️
crates/storage/src/sled/spec_activation.rs 84.84% 15 Missing ⚠️
crates/params/src/runtime.rs 91.35% 14 Missing ⚠️
crates/worker/src/service.rs 93.42% 5 Missing ⚠️
crates/worker/src/test_utils.rs 87.50% 5 Missing ⚠️
crates/worker/src/state.rs 98.26% 3 Missing ⚠️
Files with missing lines Coverage Δ
bin/asm-runner/src/bootstrap.rs 99.16% <100.00%> (ø)
bin/asm-runner/src/storage.rs 100.00% <100.00%> (ø)
crates/params/src/lib.rs 100.00% <100.00%> (ø)
crates/params/src/spec_id.rs 100.00% <100.00%> (ø)
crates/storage/src/sled/mod.rs 100.00% <ø> (ø)
crates/worker/src/builder.rs 94.44% <100.00%> (ø)
crates/worker/src/traits.rs 100.00% <100.00%> (ø)
crates/worker/src/state.rs 98.14% <98.26%> (ø)
crates/worker/src/service.rs 98.13% <93.42%> (ø)
crates/worker/src/test_utils.rs 94.70% <87.50%> (ø)
... and 3 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Commit: 7ecb9f6
SP1 Execution Results

program cycles gas
asm-stf 136,449,438 134,170,529
moho 5,223,535 5,525,318

@prajwolrg
prajwolrg force-pushed the upgradability-spec-activation branch 4 times, most recently from f775990 to aed3dc0 Compare July 26, 2026 00:54
@prajwolrg
prajwolrg marked this pull request as ready for review July 26, 2026 00:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aed3dc0ff3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

/// enacted on the abandoned branch must not leak into re-processing;
/// any still on the new branch are re-discovered as its blocks re-apply.
pub(crate) fn rollback_spec_activations(&mut self, base_height: L1Height) -> WorkerResult<()> {
self.context.prune_spec_activations_after(base_height)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key activations by the enacting block identity

When a reorg returns to a previously processed block at the same height, sync_to_block initially ignores that stored target and later rebases from it when a child arrives, but this height-only prune keeps activations enacted by the abandoned block at that height. For example, after processing block B at height 150, switching to A at height 150 with an upgrade, and then switching back to B, rebasing from B retains A's activation and predicate, so block 151 is assigned the wrong effective schedule. Store the enacting block commitment with each activation and discard records that do not belong to the selected base's ancestry rather than deciding solely by height.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a645f9788

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// drop them before re-processing so the effective schedule cannot leak
// a rolled-back activation into the first re-processed block. A linear
// extension has nothing above the base, making this a no-op.
state.rollback_spec_activations(base_block.height())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve activations until the replacement branch commits

When a reorg is attempted after an upgrade, this permanently deletes the old branch's activation records before any replacement block is committed. If fetching, transitioning, or storing a pending block then fails, process_input shuts the worker down while the old anchor states remain durable; on restart, AsmWorkerServiceState::new adopts the highest stored old anchor but reconstructs its schedule without the deleted activation. This leaves a committed anchor paired with the wrong effective schedule and can cause the next upgrade to be assigned the wrong successor, so pruning must be committed atomically with the replacement branch or deferred until that branch is durable.

Useful? React with 👍 / 👎.

"anchor": asdict(self.anchor),
"subprotocols": self.subprotocols,
"spec_activation": {"v1": self.v1_height},
"spec_activation": {"v0": self.v0_height, "v1": self.v1_height},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Omit unscheduled versions from generated parameters

When v1_height is left as the documented None, this emits "v1": null, but SpecScheduleRepr now maps values directly to L1Height and its tests explicitly reject that representation; an unscheduled version must be absent. Consequently callers selecting the advertised unscheduled configuration—and existing disabled-version parameter files produced in the former format—fail during ASM parameter deserialization instead of starting the runner.

Useful? React with 👍 / 👎.

@prajwolrg
prajwolrg changed the base branch from asm-upgradability to upgradability-params-spec July 26, 2026 11:43
@prajwolrg
prajwolrg changed the base branch from upgradability-params-spec to asm-upgradability July 26, 2026 11:43
Comment on lines +33 to +42
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "SpecScheduleRepr", into = "SpecScheduleRepr")]
pub struct SpecSchedule {
/// Activation height of each scheduled post-genesis version, indexed by
/// predecessor count: `upgrades[i]` activates discriminant `i + 1`.
upgrades: Vec<L1Height>,
}

/// A schedule update that would violate [`SpecSchedule`]'s invariants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Derive order, and in other places.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The codebase is split between the order of Debug, Clone vs Clone, Debug. I think this is also true if we put PartialEq before Eq.

Are you suggesting to put all the derive in the alphabetical order except for the non-standard derive?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order that I've always used is:

  1. std derives: in decreasing order of how "essential" the trait is, so Copy, Clone, Debug, Hash, and then the rest
  2. non-std derives: in alphabetical order, so Deserialize, Serialize although this can get confusing when different crates end up getting interleaved and I don't have a good solution

but uhh I guess it doesn't really matter

Comment thread crates/params/src/runtime.rs
Comment thread crates/params/src/runtime.rs
Comment thread bin/asm-runner/src/worker_context.rs

@delbonis delbonis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into the GitHub bug where it submits your PR reviews without you asking, so this comment also applies to the review that GitHub submitted for me a couple mins ago:

There's a couple of design questions I raised here, overall it's not bad.

Comment thread crates/worker/src/traits.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92b108866e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# gapped schedule is rejected). Dynamic activations of later versions
# come from enacted ASM VK upgrades.
v0_height: int = 0
v1_height: int | None = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the unscheduled V1 default in the factory builder

Although this field now defaults to None, build_asm_params still defaults its v1_height argument to 0 and passes it explicitly, while basic_env.py calls that helper without an override. Those generated environments therefore start with V1 already scheduled at height 0; because activation discovery selects the successor of the newest scheduled version, the first AsmStfUpdate is treated as unsupported V2 and shuts down the worker instead of activating V1. Align the helper's default with this new genesis schedule.

Useful? React with 👍 / 👎.

Fork awareness is meant to live at the spec level: the version table in
strata-asm-spec and the schedule the worker and guest feed it.
Subprotocols stay fork-unaware — upgrades ship as whole versioned
implementations — so nothing below params ever needs SpecId, and keeping
it in common wrongly advertised it as subprotocol vocabulary.

Inside params, identity and schedule split: spec_id.rs keeps the SpecId
enum, runtime.rs owns SpecActivation and the STF/runtime params.
SpecActivation was common's last serde user and params' only common
import, so common drops serde and params drops strata-asm-common.
SpecId previously started at V1 with genesis rules modeled as "no spec
version active". Give the genesis rules their own version instead: V0
is always active since genesis and V1 becomes the first on-chain
upgrade. This lets every ASM VK update name the version its artifact
implements — including updates that will ship while still on the
genesis spec — and gives gates a uniform is_active check.

The schedule (SpecSchedule) encodes those rules structurally instead of
one Option field per version: V0 is implicit and later versions only
ever activate in succession, so a gapped or v0-disabled schedule is
unrepresentable and a new SpecId variant is a one-line change — there
is no per-version code to keep in sync. The serialized form keeps the
per-version map ({"v0": 0, "v1": null}) and re-validates the
invariants on load, so a hand-edited schedule fails fast.

The raw identity narrows to u16 only: the discriminant is repr(u16),
the u8 conversions are gone, and the primitive conversions are derived
(num_enum, erroring with the raw id) so they cannot go stale when a
variant is added.
Discovered spec activations must survive worker restarts: the enacted
update is dropped from the admin queue at enactment, so without a
durable record a restarted worker could not reconstruct which versions
the chain has activated (or the VK each boundary switched to).

Keyed by (enacting_height, version) with a big-endian height prefix so
put is an idempotent overwrite under crash-replay and prune_after (the
reorg path) is a range scan. The predicate is the value, borsh-encoded.
The version is stored and returned as its raw u16 id: mapping it to a
known spec version is act-time worker logic, so the store stays opaque
to it (the worker owns the typed record).
New SpecActivationStore concern on the worker context, wired to the sled
store in the runner and the test context. The SpecActivationRecord it
persists lives here rather than in strata-asm-common: a discovered
activation is worker bookkeeping, not protocol surface (keeping it out
of common also keeps strata-predicate out of the guest's common deps).
The store speaks raw parts — the context impls map the raw version id
back through SpecId on load, which is where act-time mapping belongs.

Record-before-anchor-commit and prune-above-base are contracts the
discovery logic (next commit) relies on for crash and reorg safety; they
are documented on the trait so every backend upholds them.
The worker maintains an effective spec schedule: the base from runtime
params (a new required builder input) overlaid with every activation
discovered from enacted AsmStfUpdate logs. The log carries no version —
future versions must be enactable by artifacts that predate them, so
the wire cannot require knowing them. Instead each enacted upgrade
activates the successor of the newest scheduled version
(SpecSchedule::schedule_successor). A successor this binary has no
variant for halts the worker without committing the block: it is
running old software past an upgrade it cannot execute, and limping
along on stale rules would silently diverge from the chain. Nothing is
persisted for the rejected block, so a restart with a newer image
simply retries it. Replaying persisted activations onto the base
schedule is validated the same way: a record the schedule cannot fit
(e.g. params downgraded below activations already committed) fails
startup instead of silently producing a gapped schedule.

Crash/reorg safety is carried entirely by ordering: the activation
record is written before the enacting block's anchor commit (a
committed anchor can never lack its activation), and every sync rebase
prunes activations above the base before re-processing, so a
reorged-out enactment cannot leak into the new branch — its blocks
re-discover whatever survives. The activation height is derived, not
stored: always the block after the enacting one.

Nothing consumes the schedule yet — threading it into the STF hooks
lands with the AsmSpec pipeline follow-up.
Worker-level integration coverage for the full on-chain choreography:
the admin enacts an ASM VK upgrade, the worker derives V1 as the
successor of genesis-active V0 and records the activation at H+1
carrying the enacted VK, and the enacting block's manifest holds the
log it was discovered from. The reorg path abandons the submission
block and asserts the activation rolls back, then re-enacts one height
later once the resurrected admin txs re-mine.

The unstake-style feature gating that motivated the old fork-based
variant of this test is deliberately absent: nothing consumes the
schedule yet.
…chedule entries

Both mutation paths now reject an activation height that would order a
version against its neighbor the wrong way, so an inverted schedule ("v2
active while v1 is not") is unrepresentable; equal heights stay legal
because several upgrades enacted in one block activate at the same height.
The worker maps an unknown successor to UnsupportedSpecActivation as
before and an out-of-order activation to InconsistentSpecSchedule.

The serde form now lists exactly the scheduled versions: an explicit
"v1": null was indistinguishable from omitting the key, so absence becomes
the only spelling of "unscheduled" and null is rejected at decode.
Public fields let callers construct or mutate a record without going
through from_raw, bypassing the SpecId validation it does. Make the
fields private and add a new() constructor plus accessors, so
construction always goes through one of the two typed entry points.
@prajwolrg
prajwolrg force-pushed the upgradability-spec-activation branch from 92b1088 to ce8eb2f Compare July 30, 2026 10:41
@prajwolrg
prajwolrg requested a review from delbonis July 30, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants