linera-io/linera-protocol · error
Attempt to modify storage from a service
Error message
Attempt to modify storage from a service
What it means
linera-sdk's system API for views dispatches write_batch differently per runtime interface. Contract runtimes forward batches to the host to mutate chain state; service runtimes are read-only by protocol design, so write_batch on WitInterface::Service panics with 'Attempt to modify storage from a service'. Any state mutation must go through contract operations inside blocks.
Source
Thrown at linera-sdk/src/views/system_api.rs:343
WitInterface::Service => service_wit::find_key_values_wait(promise),
#[cfg(with_testing)]
WitInterface::Mock { store, .. } => store.find_key_values_wait(promise),
}
}
/// Calls the `write_batch` WIT function.
fn write_batch(&self, batch: Batch) {
match self {
WitInterface::Contract => {
let batch_operations = batch
.operations
.into_iter()
.map(WriteOperation::from)
.collect::<Vec<_>>();
contract_runtime_api::write_batch(&batch_operations);
}
WitInterface::Service => panic!("Attempt to modify storage from a service"),
#[cfg(with_testing)]
WitInterface::Mock {
store,
read_only: false,
} => {
store.write_batch(batch);
}
#[cfg(with_testing)]
WitInterface::Mock {
read_only: true, ..
} => {
panic!("Attempt to modify storage from a service")
}
}
}
}
/// Implementation of [`linera_views::context::Context`] to be used for data storageView on GitHub (pinned to 6c226ddcb3)
Solutions
- Move the mutating logic into a contract operation; services may only read views
- In the service, replace save()/write calls with read-only getters (get, load, etc.)
- If a query should change state, have the client submit the corresponding operation via GraphQL instead
- Keep shared state-manipulation code in modules callable only from the contract side
Example fix
// before (service handle_query)
async fn handle_query(&self, _: Query) -> Result<...> {
self.counter.set(self.counter.get().await? + 1);
self.counter.save().await?; // panics: write_batch on service
}
// after (read-only service)
async fn handle_query(&self, _: Query) -> Result<...> {
Ok(self.counter.get().await?)
}
// increments happen in the contract's operation entrypoint Defensive patterns
Strategy: validation
Validate before calling
// In the service, only touch read APIs; guard against accidental persistence:
#[cfg(debug_assertions)]
fn assert_read_only<T: std::fmt::Debug>(_: &T) {} // placeholder for lint hooks
// Review rule: handle_query must never call `.save().await` or mutate collections. Prevention
- Keep state-mutating code in modules imported only by the contract, not the service
- Code-review every handle_query for save/write calls
- Remember the protocol: queries are free and read-only; operations (in blocks) mutate state
When it happens
Trigger: Inside a service's handle_query (or any service-side code), calling a view method that persists: view.save().await, ViewStorage write operations, queue pushes — anything that eventually calls write_batch on the service interface.
Common situations: Porting a contract method into the service; forgetting that services answer queries without creating blocks; attempting to cache/memoize state in views during queries.
Related errors
- Failed to deserialize instantiation argument {argument:?}
- Query {argument:?} is invalid and could not be deserialized
- A Wasm runtime is required to load user applications. Please
- An Evm runtime is required to load user applications. Please
- ServiceOracleQueryOperations
AI-assisted analysis of linera-io/linera-protocol@6c226ddcb3 (2026-08-22).
Data as JSON: /api/errors/3d9cd8fa10b6c112.
Report an issue: GitHub.