serviceability: halt and resume a feed - #4307
Conversation
`FeedStatus` and the gate that reads it landed with the subscriber work, but nothing could move the status. A staked feed sat in `Pending` for life and no feed had a way to stop publishing. Two instructions and the transitions they refuse. Halt goes only from `Active`, resume only from `Halted`, and each refusal says which way it is wrong rather than sharing one error. `Pending` to `Active` is deliberately absent: that step re-reads the stake mirror, so it belongs with the code that does, and leaving it out here keeps the two apart. The feed's own builder may sign either one, which no other feed instruction allows. RFC-28 makes halt the builder's lever and doubles it as a way to rotate the upstream source without redeploying, so a builder that cannot halt its own feed cannot do either. A `FEED_AUTHORITY` or `FOUNDATION` key may sign as well, because a feed whose builder has gone quiet must still be stoppable.
There was a problem hiding this comment.
🟢 Approval recommended
The on-chain lifecycle transitions, authorization rules, instruction wiring, error mappings, and new integration tests are consistent and complete for the stated D1 scope.
Pull request overview
This pull request adds feed lifecycle controls to the DoubleZero serviceability on-chain program so an authorized signer can halt an Active feed and resume a Halted feed, unblocking status transitions now that FeedStatus gating exists.
Changes:
- Add
HaltFeed(variant 120) andResumeFeed(variant 121) instruction variants, entrypoint routing, and processors that enforce the transition table viaFeedNotHaltable(124) andFeedNotResumable(125). - Add a shared
require_feed_writerauthorization helper that allows either the feedbuilderor aFEED_AUTHORITY | FOUNDATIONsigner to perform lifecycle changes. - Add a new SVM test suite covering allowed transitions and refusal cases, including authorization-path differentiation.
File summaries
| File | Description |
|---|---|
| smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs | New processor to transition Active -> Halted with explicit refusal error otherwise. |
| smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs | New processor to transition Halted -> Active with explicit refusal error otherwise. |
| smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs | Expose halt/resume modules and add require_feed_writer to support builder-or-permission authorization. |
| smartcontract/programs/doublezero-serviceability/src/instructions.rs | Add instruction variants 120/121 plus unpack/name/debug wiring. |
| smartcontract/programs/doublezero-serviceability/src/entrypoint.rs | Route HaltFeed / ResumeFeed to the new processors. |
| smartcontract/programs/doublezero-serviceability/src/error.rs | Add FeedNotHaltable/FeedNotResumable and map them to custom codes 124/125. |
| smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs | New integration tests for lifecycle transitions and refusal/authorization behavior. |
| crates/doublezero-serviceability-instruction/src/feed.rs | Add instruction builders for halt_feed and resume_feed. |
| CHANGELOG.md | Document the new instructions, transition constraints, and signer rules. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
armcconnell
left a comment
There was a problem hiding this comment.
Checked against edge-feed-spec GLOSSARY.md (a5f5cdf): conforms.
See inline comments.
Three review comments, and the first one is a hole. Resume took the same signers as halt, so a builder undid an operator's halt the moment it landed. `Retired` is unreachable until D2 and `DeleteFeed` refuses a staked feed, so that halt is the only lever an operator has, and a builder that can reverse it leaves no lever at all. `Feed` now records `halted_by`, and a halt by anyone other than the builder takes an operator to lift. A builder's own halt is still the builder's to lift, which is the source rotation the whole instruction exists for. Resume also re-proves the stake. A mirror can be corrected downward while a feed sits halted, so resuming on the check made at creation would let a feed publish at a rate its stake no longer backs. A staked feed that omits its mirror is refused rather than read as having no stake to check. The builders had no test. They now pin the tag byte and the account metas, which is what catches a wrong variant: `unpack` matches the leading byte by hand with a catch-all, so the compiler never sees it.
armcconnell
left a comment
There was a problem hiding this comment.
Checked against edge-feed-spec GLOSSARY.md (a5f5cdf): conforms.
See inline comments.
Adding a field to `Feed` broke the fixture generator's exhaustive literal and left the Python and TypeScript decoders reading a feed that ends one field early. The Rust compiler caught the first and could not catch the other two. `halted_by` is appended after `status`, so a feed written before it reads as halted by nobody. That is right rather than merely convenient: such a feed cannot have been halted. `resume_feed` also never appended the stake mirror, so resuming a staked feed through this builder always failed `StakeMirrorMissing`. It now takes the mirror, `None` for a catalog feed, and the test pins where it lands.
`CreateFeed` reads the tier from the mirror and writes the feed's key onto it to claim the stake, and the builder never sent it, so a staked create through this crate always failed. Its callers passed the account by hand. Derived rather than taken as a parameter. Unlike `resume_feed`, the args already carry the stake the mirror is seeded on, so asking a caller for it would only add a way to get it wrong. A catalog feed sends none, so a caller that sent neither before is unaffected.
armcconnell
left a comment
There was a problem hiding this comment.
Checked against edge-feed-spec GLOSSARY.md (887c705): conforms apart from one banned-word use, noted inline.
See inline comments.
Implements D1.
FeedStatusand the gate that reads it landed with the subscriber work, but nothing could move the status: a staked feed sat inPendingfor life, and no feed had a way to stop publishing.The transition table
HaltFeedResumeFeedPendingActiveHaltedHaltedActiveRetiredEach refusal says which way it is wrong,
FeedNotHaltable(124) orFeedNotResumable(125), rather than sharing one error. A caller that halts twice should learn it did not stop something a second time, not read a generic argument failure.PendingtoActiveis deliberately absent. That step re-reads the stake mirror, which is the only thing that catches a mirror corrected after it admitted a feed, so it belongs with the code that does it (G1). Leaving it out here keeps the two apart rather than half-implementing it.Who can sign
The feed's own
buildermay sign either instruction. No other feed instruction consultsfeed.builderat all; they authorize onFEED_AUTHORITY | FOUNDATIONand stop there.Both paths are needed, for different reasons:
FEED_AUTHORITYorFOUNDATIONkey, because a feed whose builder has gone quiet must still be stoppable, and every other feed instruction already works that way.A catalog feed has no builder, so only the second path applies to it. The check compares against the default pubkey explicitly rather than relying on nobody being able to sign as it.
Testing Verification
Five tests in a new
feed_lifecycle_test.rs. A new file rather than helpers bolted ontofeed_test.rs, because itsinit_stakedhardcodes a builder the test cannot sign as, and other tests depend on that.Pendingfeed neither halts nor resumes. This also guards G1's territory: nothing here moves a feed out ofPending.Pendingfeed can be halted by anyone and G1 does not exist yet to make a staked feedActive. The errors are what distinguish them: the builder reaches the status check and getsFeedNotHaltable, the stranger does not get past authorization and getsNotAllowed.That last one is the honest shape of the test available today. A staked feed cannot reach
Activeuntil G1, so the builder's successful halt cannot be tested yet, and asserting on which check stopped the call is the strongest available evidence that the authorization path works.Full suite passes: 327 serviceability tests plus the 5 new ones, 86 in the instruction crate,
clippy --all-targets -Dwarningsandfmt --checkclean.One thing the compiler could not catch, worth knowing for the next variant:
DoubleZeroInstruction::unpackmatches the leading byte by hand with a catch-all, andFrom<ProgramError> for DoubleZeroErrormatches the code the same way. Both compile fine with a variant missing. The first showed up asInvalidInstructionDataat runtime and the second as a round-trip test failure.