Skip to content
Merged
Show file tree
Hide file tree
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
32 changes: 9 additions & 23 deletions docs/adr/0001-engine-toml-separate-from-nexum-toml.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,24 @@
---
status: proposed
implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9
status: accepted
---

# Operator config (`engine.toml`) is separate from module manifest (`module.toml`)

## Context

The engine needs two distinct kinds of configuration: what the **operator** decides at deployment time (which chains to connect to, where the local-store database lives, which modules to boot) and what the **module developer** declares at build time (required and optional capabilities, HTTP allowlist, module-specific config keys). These have different reviewers, different threat models, and change on different cadences.

The filenames need to signal who owns each file directly. An operator opening a config file should know without prior context whether the file is their concern or the module developer's. A name like `nexum.toml` requires the reader to know that "nexum" refers to the runtime that hosts the module, which is one indirection too many; `module.toml` reads as "the module's manifest" with no prior context.
The runtime carries two kinds of configuration with different owners, reviewers, and change cadences: what the operator decides at deployment time (chains, local-store location, which modules to boot) and what the module developer declares at build time (required and optional capabilities, HTTP allowlist, module config keys). A module's capability declaration is a property of the build, so it belongs in the published bundle, not in the operator's local file.

## Decision

Two distinct files, distinct schemas, distinct loaders:

- **`engine.toml`** - operator-owned, lives next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (state_dir, log_level), `[chains.<id>]` (rpc_url), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`.
- **`module.toml`** - module-developer-owned, ships in the module's bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`.
Two files, two schemas, two loaders:

The engine config carries the path to each module's manifest; the two never collapse into one file. The names `engine.toml` and `module.toml` map directly onto the two distinct roles, so a reader reaching either file knows whose concerns it covers.
- **`engine.toml`** operator-owned, next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (`state_dir`, `log_level`), `[chains.<id>]` (`rpc_url`), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`.
- **`module.toml`** module-developer-owned, ships in the module bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`.

## Considered options

- **Single `shepherd.toml` with `[engine]`, `[chains]`, `[[modules]]` *and* nested `[modules.<n>.capabilities]` per module.** Rejected: conflates operator and developer concerns. A module's capability declaration is a property of the build, not the deployment - it belongs in the artifact, not in the operator's local file. Auditing a module's capabilities also becomes a per-deployment exercise instead of a property visible in the published bundle.
- **Keep the `nexum.toml` filename for the module manifest.** Rejected: the name does not signal who owns the file (engine vs module). `module.toml` reads as "the module's manifest" without prior context.
- **`module.toml` inside the engine config (module entries embed it inline).** Rejected for the same reason as the single-file proposal; also bloats `engine.toml`.
- **Drop `engine.toml` entirely; pass everything as CLI flags or env vars.** Rejected: per-chain RPC URLs and module lists are awkward as flags, and `RUST_LOG` already covers the only thing that env vars naturally express.
The engine config carries each module's manifest path; the two files never collapse into one.

## Consequences

- A deployment needs both files. A missing `engine.toml` falls back to "no chains, default state_dir" - the example logging module still runs; cow-api / chain backends report `unsupported`.
- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3.
- Module-bundle redistribution carries `module.toml` with the artifact; engines do not need to ship templates.
- Future content-addressed module distribution (0.3) embeds `module.toml` in the bundle hash; `engine.toml` references modules by content address rather than filesystem path. The split survives that migration unchanged.
- Implementation impact: `crates/nexum-engine/src/manifest.rs` and `engine_config.rs` need to update the filename lookup from `nexum.toml` to `module.toml`. The 0.1-compat fallback in `manifest::fallback_manifest()` should accept both names during the transition; after 0.3 only `module.toml` is recognised.

_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._
- A deployment needs both files. A missing `engine.toml` falls back to no chains and the default `state_dir` (`./data`); the example logging module still runs, chain-backed capabilities report `unsupported`.
- A `module.toml` without a `[capabilities]` block triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest` (`crates/nexum-runtime/src/manifest/load.rs`) and treats every linked capability as required.
- Module-bundle redistribution carries `module.toml` with the artifact; engines ship no templates.
31 changes: 10 additions & 21 deletions docs/adr/0002-provider-pool-transport-by-scheme.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,28 @@
---
status: proposed
implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9
status: accepted
---

# Per-chain alloy provider transport selected by URL scheme

## Context

`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport (`eth_subscribe` is push-only over a long-lived connection); request/response works on either HTTP or WebSocket. The operator configures one RPC endpoint per chain in `engine.toml`; the engine has to decide which alloy transport to use.
`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport; request/response works on either HTTP or WebSocket. The operator configures one `rpc_url` per chain in `engine.toml`, and the runtime picks the alloy transport from it.

## Decision

The `ProviderPool::from_config` constructor reads each chain's `rpc_url` and switches by URL scheme prefix:
`ProviderPool::from_config` switches on the URL scheme:

- `ws://` or `wss://` → `ProviderBuilder::new().connect_ws(WsConnect::new(url))`. Pubsub transport. Subscriptions and request/response both work. **This is the recommended configuration for any chain a module subscribes to.**
- `http://` or `https://` → `ProviderBuilder::new().connect_http(parsed)`. HTTP transport. Request/response only; `subscribe-blocks` and `subscribe-logs` surface as `fault.unsupported` to the guest.
- `ws://` / `wss://` connect via `connect_ws`. Pubsub transport: subscriptions and request/response both work. Recommended for any chain a module subscribes to.
- `http://` / `https://` connect via `connect_http`. Request/response only; `subscribe-blocks` and `subscribe-logs` return `fault.unsupported` to the guest.

Both transports erase to `DynProvider` so the rest of the engine is transport-agnostic.

Alloy is capable of emulating `eth_subscribe` on HTTP via polling, but this is intentionally **not** enabled. The engine takes an opinionated stance favouring WebSockets for subscriptions; operators who want push-based events configure WSS endpoints. HTTP-only chains are supported for `request` traffic but not for subscriptions.
Both erase to `DynProvider`, so the rest of the runtime is transport-agnostic. Alloy can emulate `eth_subscribe` on HTTP by polling; this is deliberately not enabled.

## Non-goals

- **RPC failover, load balancing, and retry policies are explicitly out of scope for the engine.** This logic lives in upstream crates (alloy ships tower-style middleware for timeout / retry / rate-limit / fallback endpoint). The engine does not roll its own. Operators wanting failover configure it via alloy provider builders before passing them through, or rely on the provider's own fallback (Alchemy, Infura, etc. handle it server-side).
- Re-routing requests across chains, rebalancing across pools within a chain, and similar provider-management concerns are likewise alloy's responsibility.

## Considered options

- **Force WSS everywhere.** Rejected: many providers (Alchemy, Infura, self-hosted RPC) expose HTTP-only on free tiers, and modules that only need `request` (no subscriptions) shouldn't be blocked by a WSS requirement.
- **Explicit `transport = "ws" | "http"` field per chain in `engine.toml`.** Rejected for 0.2: redundant with the URL scheme, and operators already distinguish `wss://` from `https://` endpoints when copying them from their RPC provider's dashboard. Revisit if we add IPC (`/path/to/geth.ipc`) - scheme alone won't carry that.
- **Open both an HTTP and a WSS connection per chain.** Rejected: doubles connection count for the common case where one endpoint serves both, and forces operators to provide two URLs even when their provider returns identical data on both.
RPC failover, load balancing, and retry policy are out of scope. Alloy ships tower-style middleware for timeout, retry, rate-limit, and fallback endpoints; operators configure it on the provider builder, or rely on their provider's server-side fallback.

## Consequences

- Operators that need subscriptions must supply WSS URLs; HTTP-only chains downgrade to request-only mode at the host call boundary.
- Connection failures at boot are fatal (the engine refuses to start with a broken chain). This is intentional - silent fall-back to a half-functioning state masks misconfiguration that a module then rediscovers at first event.
- Adding IPC support is additive: extend the scheme match with `/` / `file://` and call `connect_ipc`.
- The `DynProvider` erasure costs a virtual dispatch per call - a measurable concern at scale, deferred to M4 if profiling shows it.
- Operators needing subscriptions supply WSS URLs; HTTP-only chains downgrade to request-only at the host call boundary.
- Connection failure at boot is fatal: the runtime refuses to start with a broken chain rather than masking misconfiguration a module rediscovers at first event.
- Adding IPC is additive: extend the scheme match with `file://` and call `connect_ipc`.
43 changes: 9 additions & 34 deletions docs/adr/0003-local-store-namespacing.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,24 @@
---
status: proposed
implemented-in: nullislabs/shepherd#8
status: accepted
---

# Per-module namespacing in `local-store` via 32-byte deterministic hash prefix

## Context

`nexum:host/local-store` is a key-value store shared across all modules the engine runs. Two modules using the same key string (e.g. `"last-block"`) must see disjoint values; one module must never read or overwrite another's data. The engine knows each module's identity at instantiation time, so namespacing is a host-side concern.

Two properties matter for the namespace prefix:

1. **Deterministic and unspoofable.** An arbitrary `module_name` string read out of `module.toml` lets a malicious or careless operator give two modules the same name and have one read the other's state. A fixed-size hash derived from the module's canonical identity is harder to collide and removes the operator-supplied-text attack surface.
2. **Composes with ENS-based module discovery** (per `docs/03-module-discovery.md`): when a module is identified by an ENS name (e.g. `twap-monitor.shepherd.eth`), the ENS namehash is a natural prefix. ENS TXT records pinning the `.wasm` content hash provide a separate verification path against the loaded bundle.
`nexum:host/local-store` is a key-value store shared across every module the runtime runs. Two modules using the same key string must see disjoint values, and one module must never read or overwrite another's data. The runtime knows each module's identity at instantiation, so namespacing is a host-side concern. The prefix must be deterministic and unspoofable: an operator-supplied `module_name` string would let two modules collide by name, so the prefix derives from the module's canonical identity as a fixed-size hash.

## Decision

Single redb database file at `EngineConfig.engine.state_dir`, single shared table `nexum:local-store`. Every key handed to redb is composed host-side as:

```
[32-byte namespace prefix][raw key bytes]
```

The 32-byte prefix is computed deterministically from the module's canonical identity:

- **ENS-identified modules** (M3+, per `docs/03`): prefix is `ens_namehash(name)` (EIP-137), e.g. `namehash("twap-monitor.shepherd.eth")`.
- **Locally-loaded modules** (current 0.2 scope, no ENS): prefix is `keccak256(module_name)` where `module_name` comes from `module.toml`'s `[module].name` field.

Both produce a 32-byte digest with the same domain, so a module loaded locally during development and later published under an ENS name can keep its existing state by registering an alias (`alias = keccak256(name)`) the engine recognises during the migration window. The exact alias mechanism is out of scope for this ADR.

Modules see plain key strings on both the read and write paths; the prefix is invisible to the WIT-facing API.
Single redb database file at `EngineConfig.engine.state_dir`, single shared table. Every key handed to redb is composed host-side as `[32-byte namespace prefix][raw key bytes]`.

## Considered options
The prefix is `keccak256(module_name)`, where `module_name` is `module.toml`'s `[module].name`. keccak256 shares the domain of the ENS namehash, so a module loaded locally and later published under an ENS name (see `docs/03-module-discovery.md`) can keep its state via an alias registered during migration; the alias mechanism is out of scope here.

- **Separator string** (`{module}:{key}`). Rejected: any module name containing `:` collides with another module's `:`-bearing key. A fixed-size hash is unambiguous regardless of payload bytes.
- **`[len:u8][module_name][key]` length-prefixed string.** Rejected: spoofable (the name is operator-supplied text), and does not align with the ENS-based discovery path that 0.3 will introduce. The 32-byte hash is deterministic and namespace-uniform.
- **One redb database file per module.** Rejected: multiplies open file handles linearly in modules, blocks any future cross-module atomic operations (not currently planned but cheap to keep on the table), and complicates backup tooling (N files vs 1).
- **One redb *table* per module within a single file.** Rejected: redb `TableDefinition` lifetimes are `'static`, so table names must be known at compile time. Dynamic table opening per module would force string-leak workarounds and exposes the same name-collision question as separator-based keys.
- **Engine-allocated incrementing module id.** Rejected: stable across reboots only if the engine persists the allocation table, which adds a chicken-and-egg dependency on the local-store itself. Determinism from the name avoids the dependency entirely.
Modules see plain key strings on both paths; the prefix is invisible to the WIT API.

## Consequences

- The prefix is fixed-size (32 bytes) and independent of module name length. Range scans over a single module's keys are O(log n + module-key-count) - fine for our workload.
- Migrations changing the prefix derivation (e.g., switching the local-mode hash function or the ENS resolver) would orphan every existing module's persisted state. The derivation must stay stable through 0.x; ENS-mode introduction in 0.3 happens additively via the alias mechanism, not by changing existing prefixes.
- A module's `list-keys` iterates over the namespace range (32-byte prefix scan); the host strips the prefix before returning to the guest.
- Module data versioning (schema migrations across module versions) is the module's responsibility. The local-store does not version values; modules MAY embed a `schema_version` byte in their stored payloads and migrate on `init` when the read value's version differs from the current code's expectation.
- ENS-based discovery (per docs/03) integrates without a prefix-format change: when a module is loaded by ENS name, the prefix is `namehash(name)`. The corresponding `.wasm` content hash is verified via ENS TXT records before loading, separately from the local-store prefix derivation.
- Spoofing protection: an operator cannot make module A read module B's state by renaming, because the prefix is the hash of the canonical name. Renaming a module to match another's name produces a name conflict the engine refuses at boot, rather than silent state takeover.
- The prefix is fixed-size and independent of key length. A module's `list-keys` iterates the 32-byte prefix range; the host strips the prefix before returning to the guest.
- Changing the prefix derivation would orphan every module's persisted state, so the derivation stays stable through 0.x; ENS-mode namespacing is introduced additively via the alias mechanism, not by changing existing prefixes.
- The store does not version values. Modules that need schema migration embed their own version marker in stored payloads and migrate on `init`.
- An operator cannot make module A read module B's state by renaming: matching names produces a boot-time conflict, not a silent state takeover.
Loading
Loading