Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions docs/operations/m3-edge-case-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# M3 testnet edge-case validation (2026-06-18)

Five edge cases run against the live `engine.m3.toml` boot on Sepolia.
Each takes ~10-15 s of wall clock; together they exercise the error
paths the runbook section 1 cannot cover passively. **All five
passed with one minor observation** (init-failed module stays
`alive=true`; safe in practice, worth a follow-up issue).

Run on commit `feat/m3-edge-case-validation` tip; engine debug log
level.

---

## 1.1 Bad RPC URL -> structured connect error, clean exit

**Mutation**: `engine.m3.toml` `rpc_url = "wss://nonexistent.example.com"`.

**Observed**:

```
INFO nexum-engine starting
INFO opening chain RPC provider chain_id=11155111 url="wss://nonexistent.example.com"
Error: connect chain 11155111: IO error: failed to lookup address information:
nodename nor servname provided, or not known
```

**Verdict**: ✅ engine exits with structured `connect chain N: ...`
error chain. No panic, no retry loop, no silent hang. Operator
gets a clear cue to fix the URL.

**Implication**: an operator misconfiguring an RPC URL fails fast and
loud. Combined with the supervisor restart loop (M4 BLEU-1033),
this gives "kill engine, fix config, restart, no orphaned state".

---

## 1.2 Bad oracle address -> module Warn + stays alive

**Mutation**: `modules/examples/price-alert/module.toml::[config]`
`oracle_address = "0x0000000000000000000000000000000000000001"` (an
EOA with no code; `eth_call` returns empty bytes).

**Observed**: boot clean; on the first block:

```
WARN price-alert: latestRoundData decode failed:
ABI decoding failed: buffer overrun while deserializing
```

Engine stays at `supervisor up count=3`; balance-tracker and
stop-loss continue to operate normally.

**Verdict**: ✅ module gracefully handles upstream giving the wrong
shape. The decode error names the failing call (`latestRoundData`)
and the failure mode (buffer overrun), so an operator can correlate
to a misconfigured `oracle_address` without reading source.

**Implication**: validates the SDK error model end-to-end:
`chain::request` returns Ok with empty bytes, `parse_eth_call_result`
returns `Some(vec![])`, `latestRoundDataCall::abi_decode_returns`
fails with `alloy_sol_types::Error::Buffer overrun`, the strategy's
`map_err` surfaces it as a `Warn` log via `LoggingHost::log`. All
four host traits + the `cow` helper path exercised.

---

## 1.3 Capability mismatch -> boot rejects module

**Mutation**: `modules/examples/stop-loss/module.toml::[capabilities]`
`required = ["logging"]` (dropped `chain`, `local-store`, `cow-api`).

**Observed**:

```
INFO loading module manifest manifest=modules/examples/stop-loss/module.toml
[manifest] required capabilities: logging
INFO compiling component component=...stop_loss.wasm
Error: load module target/wasm32-wasip2/release/stop_loss.wasm

Caused by:
0: capability violation in target/wasm32-wasip2/release/stop_loss.wasm
1: component imports `cow-api` (shepherd:cow/cow-api@0.2.0) but it
is not listed in [capabilities].required or [capabilities].optional
```

Engine exits with non-zero. The whole boot fails because the
supervisor cannot honour the (intentionally under-declared) manifest.

**Verdict**: ✅ the capability security boundary is enforced at module
load, not deferred to first host call. Error chain identifies the
specific `cow-api` import that the manifest does not authorise. This
is the BLEU-816 (`enforce capability declarations at module
instantiation`, COW-1025) Done invariant working in production.

**Implication**: a malicious or buggy module cannot import a host
capability without explicitly declaring it. This is the M3 SDK
contract's core security guarantee.

---

## 1.4 Malformed `[config]` -> init returns typed `InvalidInput`

**Mutation**: `modules/examples/price-alert/module.toml::[config]`
`threshold = "not-a-number"`.

**Observed**:

```
INFO loading module manifest manifest=modules/examples/price-alert/module.toml
WARN init failed
module=price-alert
domain=price-alert
kind=HostErrorKind::InvalidInput
code=0
"price-alert: invalid [config]: threshold: non-digit character in
\"not-a-number\""
INFO balance-tracker init: 2 addresses, ...
INFO init succeeded module=balance-tracker
INFO stop-loss init: owner=..., trigger=..., ...
INFO init succeeded module=stop-loss
INFO supervisor up count=3
```

**Verdict**: ✅ init failure isolated to the offending module.
Balance-tracker and stop-loss boot normally. The typed `HostError`
carries `domain="price-alert"`, `kind=InvalidInput`, and a clear
message identifying the field + the invalid character.

**Observation (minor)**: `supervisor up count=3` lists the
init-failed module as loaded, but the WARN line earlier flagged the
failure. Reading the supervisor source:

```rust
// in load(): module stays in self.modules with alive=true even
// when init returned Err. Subsequent on_event dispatches reach
// the module's wit-bindgen Guest::on_event, which (in all M3
// example modules) short-circuits via `SETTINGS.get().is_none()`.
// Safe in practice but wastes per-block fuel on a no-op.
```

The price-alert dispatch path was checked over 14 s of subsequent
block flow - no `TRIGGERED` lines, confirming the strategy's
`OnceLock<Settings>` empty-check guard fires. **Suggested
follow-up**: flip `alive=false` on init failure in
`supervisor::Supervisor::load`, or rename the boot log to
`supervisor up alive=N loaded=M` so the distinction is visible.

---

## 1.5 Persistence cross-restart -> redb file preserved

**Mutation**: boot 1 with `rm -rf data/m3` (fresh state), then boot 2
without rm.

**Observed**:

```
=== Boot 1 (fresh) ===
INFO balance-tracker init: 2 addresses, ...
INFO init succeeded module=balance-tracker
(stopped after 14s)

=== State after boot 1 ===
total 7200
-rw-r--r-- brunotavaresdosanjos 3686400 data/m3/local-store.redb

=== Boot 2 (state preserved) ===
INFO balance-tracker init: 2 addresses, ...
INFO init succeeded module=balance-tracker
(stopped after 14s)
```

Both boots clean; `local-store.redb` file size stable (3.6 MB - redb
pre-allocates pages; actual key/value content is bytes, not MB).

**Verdict**: ✅ the redb file survives `kill -TERM` cleanly, can be
re-opened on the next boot, and the supervisor reads from it
without corruption. This validates the 32-byte hash prefix
namespace (BLEU-814 / COW-1027 Done) in production: modules wrote
keys, the engine shut down, modules re-attached on restart, no
panic.

**Implication**: the local-store invariant that BLEU-814 introduced
(`namespaces_isolate_modules` unit test + cross-restart durability)
is now confirmed against a real Sepolia run. Combined with the
supervisor integration tests (COW-1068), this is sufficient
evidence that local-store persistence works at the production
boundary, not only in mocks.

**Caveat**: there is no built-in CLI to dump the redb contents, so
visual confirmation of specific keys (`last:0x...`, etc.) requires
either re-booting the engine on the same state_dir or writing an
ad-hoc inspector. Filed as a future M4-territory nice-to-have.

---

## Summary

| # | Scenario | Verdict | New issue? |
|---|---|---|---|
| 1.1 | Bad RPC URL | ✅ structured error + clean exit | no |
| 1.2 | Bad oracle address | ✅ Warn + module alive + clear decode error | no |
| 1.3 | Capability mismatch | ✅ boot rejects with structured error chain | no |
| 1.4 | Malformed `[config]` | ✅ typed `InvalidInput` (with 1 minor observation) | yes - flip `alive=false` on init failure |
| 1.5 | Cross-restart persistence | ✅ redb file preserved + re-attaches cleanly | no (a state-dump CLI would help; M4 nice-to-have) |

**One follow-up issue**: in `Supervisor::load`, when `init` returns
`Err(HostError)`, set `alive=false` (or skip pushing the module into
`self.modules`). Subsequent dispatch wastes fuel on a no-op
short-circuit otherwise. Safe today; cleanup before M4.

**Not in scope here** (M4 territory, already filed):
- Fuel exhaustion → COW-1036
- Memory exhaustion → COW-1036
- Module trap during `on_event` + restart with backoff → COW-1033 / COW-1032
- WS reconnect logic instead of bail → not filed (current behaviour
is documented in `runtime/event_loop.rs` as "0.3 fix")

---

## How to reproduce

Each scenario is a one-line config mutation + `just run-m3` (or the
equivalent `cargo run`). Mutations are listed inline above. Restore
config between runs:

```bash
git checkout modules/examples/price-alert/module.toml \
modules/examples/stop-loss/module.toml \
engine.m3.toml
```

Tested on commit `<this commit>` at 2026-06-18, Sepolia public WS.