:: In active development. Codebase tracks the dev branch. Explore at your own risk. Official release coming soon.
Skip to content

Serve Subcommand

The vesl scaffold's src/main.rs is a clap dispatch with two arms — Demo (default) and Serve. The Serve arm boots out.jam, builds an AppState, and hands it to vesl_hull::serve, which mounts an axum::Router on the configured bind address.

bash
cargo +nightly run --release -- serve                  # http://127.0.0.1:3000, demo signing key
cargo +nightly run --release -- serve --no-auth        # loopback dev: skip API-key auth
cargo +nightly run --release -- serve --port 8080      # custom port, still loopback
HULL_API_KEY=mysecret cargo +nightly run --release -- serve --bind-addr 0.0.0.0   # LAN

Flags

FlagDefaultNotes
--port <PORT>3000TCP listen port.
--bind-addr <ADDR>127.0.0.1Bind address. --no-auth is refused on any non-loopback bind.
--no-authoffDisable API-key auth. Honored only when --bind-addr is loopback (127.0.0.1, ::1, localhost); otherwise the binary refuses to start.

All other behavior — settlement mode, signing key, vesl.toml overrides — flows through the same surfaces the Demo arm uses. See Settlement Modes for the mode-selection logic.

Auth Model

The Serve arm checks two configuration surfaces at startup via vesl_hull::check_auth_config_with_bind(no_auth, bind_addr):

  1. Non-loopback bind + --no-auth → refuse to start. The combination would expose an unauthenticated kernel-poke surface to the LAN; the early-exit guard is unconditional.
  2. HULL_API_KEY env var → if set and non-empty, every kernel-side endpoint requires Authorization: Bearer <key>. If unset, the server prints WARNING: HULL_API_KEY not set -- API endpoints are unauthenticated to stderr and starts anyway.

The /health endpoint is always unauthenticated regardless of HULL_API_KEY — it's the readiness probe and must answer for orchestrators that don't carry the bearer token. The endpoint is gated on AppState::kernel_ready: 200 + {"status":"ok"} once the hull finishes booting, 503 + {"status":"booting","stage":"<stage>"} until then. Wire k8s readinessProbe (or any load-balancer health check) to the 200, so traffic stays off the pod during the boot window.

bash
# Loopback dev: skip auth entirely.
cargo +nightly run --release -- serve --no-auth

# LAN / shared dev: require an API key.
HULL_API_KEY=$(openssl rand -hex 32) \
  cargo +nightly run --release -- serve --bind-addr 0.0.0.0

# Then from another host:
curl -H "Authorization: Bearer $HULL_API_KEY" \
  http://<host>:3000/status

For production deployments where the seed phrase + bearer key + bind address all matter, lock down the host running the hull — the BIP-39/BIP-44 derivation gives the hull spend authority over any UTXO locked to the resulting pkh (see Dumbnet Walkthrough).

Rate-limit behavior

The hull's middleware stack paces requests rather than rejecting them on burst. The layer is tower's RateLimit with a tower::buffer::Buffer in front:

  • Capacity: 200 requests per 60 seconds, shared across every authenticated endpoint.
  • Buffer: 256 in-flight requests. Excess in-flight requests are queued, not rejected.
  • Overflow: requests beyond the 256 buffer slot return HTTP 429 via the HandleErrorLayer wrapper. Under the capacity ceiling, requests block until a slot frees instead of failing fast.

This is pacing, not strict rate-limiting. A 300-request burst against /status takes ~110 seconds to drain (~200 served, 100 paced); zero 429s under that load. A 257-deep concurrent burst (one request above the buffer ceiling) is what produces the 429.

Custom routes mounted through serve_with_extra_routes / router_with_extra inherit the same layer (the middleware stack wraps the merged Router — see Composing Custom Routes).

Swap to tower_governor::GovernorLayer if the deployment needs true burst-rejection semantics; the existing pacing layer is wired in crates/vesl-hull/src/api/mod.rs (router_with_extra_inner).

Endpoint Catalog

vesl_hull::router(state) returns an axum::Router mounting six endpoints. The handlers assume the kernel composes settle-graft — a kernel without it will reject the kernel-side pokes those handlers issue.

MethodPathPurposeAuth
POST/commitCommit key-value fields to a Merkle tree and register the root with the kernel.HULL_API_KEY
POST/settleSettle a note against the current registered root. Body shape varies by gate — see Catalog Gates / Hull settle routing.HULL_API_KEY
POST/verifyVerify a field's Merkle proof against the registered root.HULL_API_KEY
GET/tx/{tx_id}Fetch a chain-attested receipt (requires fakenet/dumbnet settlement).HULL_API_KEY
GET/statusCurrent state snapshot — fields, tree, hull-id, note counter, settlement mode, active gate, composed grafts, per-graft manifest sha256s.HULL_API_KEY
GET/healthReadiness probe. 200 + {"status":"ok"} when the hull is ready; 503 + {"status":"booting","stage":"<stage>"} during boot. Always unauthenticated.

Each endpoint's request/response shape sits in crates/vesl-hull/src/api/handlers/<endpoint>.rs (one file per handler); the shared PokeCrashError → HTTP mapping lives in crates/vesl-hull/src/api/error.rs. The 409 / 4xx mappings for kernel rejections are documented in Effect Catalog → settle-graft.

/status Response Shape

GET /status returns the operational snapshot a hull operator triages against. The full payload after one commit + one settle against a quickstart-shaped kernel:

json
{
  "has_tree": true,
  "field_count": 1,
  "merkle_root": "5q8m7n4k2x9j6h3y8b1w7p4v2c5d8e1f9z3a6t0r4u7i2o5",
  "notes_settled": 1,
  "hull_id": 1,
  "settlement_mode": "in-process",
  "gate": "default-hash",
  "grafts": [
    "batch-graft", "clock-graft", "counter-graft", "forge-graft",
    "guard-graft", "intent-graft", "kv-graft", "log-graft",
    "mint-graft", "queue-graft", "rbac-graft", "registry-graft",
    "settle-graft", "validate-graft"
  ],
  "manifest_shas": {
    "batch-graft":    "8f3e2a1c…",
    "clock-graft":    "7b6d4f8a…",
    "counter-graft":  "2c9e1b6d…",
    "forge-graft":    "4d8a3f7c…",
    "guard-graft":    "9a3c5e2b…",
    "intent-graft":   "1f4b8d3a…",
    "kv-graft":       "6e2c1a9d…",
    "log-graft":      "3b7f4e8c…",
    "mint-graft":     "5a9d2c7b…",
    "queue-graft":    "0c4e6f1b…",
    "rbac-graft":     "8d3a7c2e…",
    "registry-graft": "f7b2e4a9…",
    "settle-graft":   "f1b2c8e4…",
    "validate-graft": "3e9d1c5b…"
  }
}

Field meanings:

  • has_tree / field_count / merkle_root — current commit-graft tree state. merkle_root is null until the first /commit; once a tree exists, it's the tip5-formatted root.
  • notes_settled / hull_id — settle-graft progress counters. notes_settled increments per successful /settle; hull_id is the namespace under which roots were registered.
  • settlement_mode"in-process", "fakenet", or "dumbnet". Set at boot from the --settlement flag.
  • gate — active verify-gate name. "default-hash" when no graft declares [graft.gates]; otherwise the selection from the highest-priority graft. Chains render as "A&B".
  • grafts — alphabetically sorted list of every graft composed into the running kernel.
  • manifest_shas — per-graft sha256 of the raw manifest TOML. Same digest nockup graft inject banners on each block, so a mismatch surfaces drift between the on-disk manifest and the composed kernel.

Verifying a gate swap via /status

/status snapshots the graft manifest dir at hull boot. After swapping a gate, restart the hull and check:

bash
curl -H "Authorization: Bearer $HULL_API_KEY" http://localhost:3000/status | jq '{gate, grafts, manifest_shas}'
json
{
  "gate": "manifest-verify",
  "grafts": ["mint-graft", "settle-graft"],
  "manifest_shas": {
    "mint-graft": "9a3c…",
    "settle-graft": "f1b2…"
  }
}

The gate field is "default-hash" when no graft declares [graft.gates]; otherwise it's the selection from the highest-priority graft (in practice, settle-graft), with gate-chain = [...] selections rendered as "A&B". The manifest_shas map mirrors the digest nockup graft inject banners on each block, so any drift between the on-disk manifest and the composed kernel surfaces here.

Hull / kernel drift triage via /status

manifest_shas is the canonical surface for catching out-of-band drift between a running hull's out.jam and the on-disk graft library. The shas it returns are the same per-graft digests nockup graft inject --apply prints on each block — so the diff workflow is one curl, one jq, and one grep:

bash
curl -H "Authorization: Bearer $HULL_API_KEY" http://localhost:3000/status \
  | jq '.manifest_shas'
nockup graft inject hoon/app/app.hoon 2>&1 | grep sha256

A mismatch points at one of three failure modes: a stale kernel (the source changed but the hull is still booted from an old out.jam), a manifest edit that wasn't re-injected (the inject banner shows the new sha but /status shows the old one), or a hull running an out.jam produced from a different lib tree than the one inject is reading now. Pair this check with vesl-test verify-jam/status catches hull / kernel drift, verify-jam catches kernel / source drift.

Composing Custom Routes

Pass your routes to vesl_hull::serve_with_extra_routes (or vesl_hull::router_with_extra if you only need the assembled axum::Router). The hull merges them with its stock endpoints before applying the middleware stack, so auth, body limit, and rate limit cover every route uniformly:

rust
use axum::{routing::post, Router};
use vesl_hull::SharedState;

async fn issue_badge(/* ... */) -> impl axum::response::IntoResponse {
    // your domain handler
}

pub async fn run(state: SharedState, port: u16, bind: &str) -> anyhow::Result<()> {
    let extra: Router<SharedState> = Router::new()
        .route("/issue-badge", post(issue_badge));
    vesl_hull::serve_with_extra_routes(state, port, bind, extra).await?;
    Ok(())
}

This is the seam for adding endpoints that drive your domain causes. The mounted Tower middleware stack wraps the merged Router, so your custom routes inherit every layer uniformly:

  • API-key auth — bearer-token check against HULL_API_KEY. The /health exemption is wired explicitly inside the auth middleware.
  • Body-size cap (two-layer, 4 MiB) — an upfront Body::size_hint precheck rejects any request whose body advertises a known length above the cap (413 Payload Too Large). That covers wire requests with honest Content-Length (axum's H1/H2 parser propagates the header into the body's size_hint) and in-process bodies built from Vec<u8> / Bytes / String. Chunked or unknown-length bodies fall through to tower-http's streaming RequestBodyLimitLayer, which fires the moment the handler polls past the cap. A handler that ignores its body still gets the upfront 413 when the size is known.
  • Rate limit — 200 req / 60 s with a 256-deep buffer; overflow returns 429 via the HandleErrorLayer wrapper.

To replace stock endpoints entirely (e.g. a domain-specific /commit shape), fork crates/vesl-hull/src/api/ rather than merging — Router::merge can't override existing route definitions, only add to them. Each stock handler is its own file under api/handlers/, so forking is "copy the one handler you want to replace + wire your version into a custom router."

Worker patterns and throughput

AppState lives behind a single Arc<Mutex<...>> so any custom route that holds the lock across a multi-step kernel poke serializes against every other endpoint. Sustained throughput tops out near 20 ops/s on a held-lock workload; a 250-deep concurrent burst against a worker-style /enqueue route takes ~12.7 seconds to drain.

If your custom route's hot path is "lock, poke kernel, write state, unlock," the mutex is the ceiling. Two shapes that lift it:

  • mpsc to a dedicated worker task. The route sends work over an mpsc::Sender; a single owner task holds the kernel handle and drains the channel. The route returns immediately with a job id, and a follow-up GET surfaces completion. Trades latency for throughput.
  • Read-mostly fast path. Routes that only need to peek (no kernel poke) can take an RwLock read guard via a refactored AppState, leaving the write path on the mutex. Tightly scoped — most hull state mutation goes through the kernel poke, which is single-threaded by construction.

Running multiple instances

Each serve process boots its own copy of out.jam into its own kernel. None of that kernel's state is shared between processes — every instance carries an independent state tree.

The kernel's note-settled set is the replay guard behind /settle: a second settle of an already-settled note_id is rejected by the kernel, which the handler maps to 409. That set lives in one kernel's state. Two instances behind a load balancer hold two independent settled sets — a note settled on instance A is unknown to instance B, so the same /settle request routed to B settles a second time and returns no 409. Registered roots from /commit and any per-graft counters diverge the same way.

In fakenet / dumbnet modes the settlement transaction also lands on-chain, so the chain — not the hull — is the cross-instance record of what settled there. The kernel's in-memory settled guard, and the 409 a client sees, stay per-instance regardless. local mode has no chain backstop at all.

Until hull state is externalized to a shared store, run a single instance, or front a fleet with sticky sessions (load-balancer affinity) so each client reaches the same kernel on every request. A fleet without one of those drops cross-instance replay rejection silently.

See Also