:: vesl-nockup beta is currently live
Skip to content

Hull ​

After reading: you'll build pokes, parse effects, dodge the four noun footguns, and know when to opt into compile-time drift detection vs. hand-roll a cause without a builder.

The hull is the Rust side of your nockapp — the program in src/main.rs that boots out.jam as a NockApp, sends pokes, and reads effects back. Most of the noun construction is done for you by vesl-core's build_*_poke helpers; you write the orchestration.

Before We Start

Three terms used throughout:

  • Atom — a non-negative integer. Hoon's primitive scalar type. Auras (@t, @ud, @tas, …) annotate how to read an atom — UTF-8 cord, decimal number, lowercase symbol — without changing the underlying value.
  • Noun — the universal value type in Nock and Hoon. Either an atom or a cell (an ordered pair of two nouns). Every value a kernel handles — state, causes, effects — is a noun.
  • NounSlab — the Rust noun container. A hull allocates nouns into a slab, builds the poke head and arguments inside it, then submits the slab to the kernel via app.poke(...). Defined at nockapp::noun::slab::NounSlab.

All three have full entries in Reference / Glossary.

HULLKERNEL HOPHULLBUILD_*_POKE(ARGS)→ NOUNSLAB (CAUSE)APP.POKE(SYSTEMWIRE, SLAB).AWAIT→ VEC<NOUNSLAB> (EFFECTS)EFFECT_HEAD_TAGS(&EFFECTS)→ ["SETTLE-REGISTERED", ...]

The Shape of a Hull ​

A hull boots the compiled kernel via nockapp::kernel::boot::setup, sends pokes with app.poke(SystemWire, slab).await, and reads back the effect list the kernel returns. The canonical shape is walked in Quickstart / Exercise the Lifecycle; the rest of this page covers the patterns inside it.

Scaffold CLI: Demo and Serve ​

The vesl template's src/main.rs is a clap dispatch with two arms. Both boot out.jam and pass the booted NockApp to the selected arm:

  • cargo run — Demo arm (default): the canonical lifecycle from the quickstart, run once.
  • cargo run -- serve — Serve arm: mounts the vesl-hull HTTP API on http://127.0.0.1:3000.

vesl-hull is a vesl-nockup-native lib factored from vesl-core/hull. Mount your own routes by passing them to vesl_hull::serve_with_extra_routes (or vesl_hull::router_with_extra for the assembled axum::Router) — not Router::merge, which attaches custom routes outside the hull's auth, body-size, and rate-limit layers. The hull's body-size cap runs in two layers: an upfront Body::size_hint precheck (catches every known-length body — in-process Body::from(Vec<u8>) and wire requests whose Content-Length axum's parser propagated into the body) plus tower-http's streaming RequestBodyLimitLayer (catches chunked or unknown-length bodies as the handler reads them). See Composing Custom Routes. The Serve arm's full surface — --port / --bind-addr / --no-auth flags, the HULL_API_KEY auth model, the endpoint catalog, and custom-router composition — lives on Build & Run / Serve Subcommand.

These handlers assume the kernel composes settle-graft — they build %register, %settle-note, and %settle-verify pokes. A kernel without settle-graft will reject those pokes; either delete the unused handlers from a fork of crates/vesl-hull/src/api/handlers/, or merge only /health and /status into a custom router.

Poke Builders ​

vesl-core ships one build_*_poke helper per shipped graft cause. Each takes typed Rust primitives in and returns a ready-to-poke NounSlab out:

rust
use vesl_core::{
    Mint, Tip5Hash,
    build_settle_register_poke, build_settle_note_poke, build_settle_verify_poke,
};

let mut mint = Mint::new();
let root: Tip5Hash = mint.commit(&[b"first"]);

let register = build_settle_register_poke(1, &root);
let note     = build_settle_note_poke(1, 1, &root, b"first");
let verify   = build_settle_verify_poke(1, 1, &root, b"first");

The three signatures don't share an argument count: register is (hull, &root), note and verify are both (note_id, hull, &root, data). The verify path takes the same 4-arg shape as note; extrapolating from register's 2-arg form produces a mismatched arguments compile error.

Tip5Hash is pub type Tip5Hash = [u64; 5]: a tip5 digest of five Goldilocks field-element limbs (each u64 below the Goldilocks prime 2^64 - 2^32 + 1). The shape matches Hoon's noun-digest:tip5 = [@ @ @ @ @]. Mint::commit returns one; the build_settle_*_poke family takes one by reference; build_mint_commit_poke(hull, root) drives the %mint-commit arm with the same digest. Convert to a 40-byte little-endian slice via vesl_core::tip5_to_atom_le_bytes when raw bytes are needed.

The full set covers settle, mint, guard, forge, plus state and behavior grafts (build_kv_set_poke, build_counter_increment_poke, build_log_append_poke, etc.). The mint family is one builder (build_mint_commit_poke); guard is two (build_guard_register_poke, build_guard_check_poke); settle's six per-gate variants follow the build_settle_note_<gate>_poke pattern. The full per-graft list is in Domain Pokes; the module tree under crates/vesl-core/src/graft_pokes/ is the source-of-truth.

For grafts that store structured data (registry, log, queue, batch), use the paired _from_noun helper to jam the payload internally rather than passing a raw &[u8]:

rust
let mut record = NounSlab::new();
record.set_root(your_noun);
let slab = build_registry_put_poke_from_noun(key, &record);

The byte-taking variants (build_registry_put_poke(key, &jammed_bytes)) trust the caller to have already jammed the payload.

Sending Pokes ​

rust
let effects = app.poke(SystemWire.to_wire(), slab).await?;

SystemWire is the standard wire identity for system-level pokes. The poke is async; app.poke returns Vec<NounSlab> — the kernel's effect list, one effect per element.

Parsing Effects ​

rust
for tag in vesl_core::effect_head_tags(&effects) {
    println!("  effect: %{tag}");
}

effect_head_tags walks each effect noun and pulls the head atom as a string. For typed effect decoding beyond the head tag, see vesl_core::effect_head_tag (singular) and the per-graft decode_*_effect helpers in the source.

Decoding Effect Payloads ​

Once you've matched on the head tag, you have to extract the rest of the effect cell. Three payload shapes recur across shipped grafts; each has a footgun.

Loobean tails — %guard-checked ok=?, %settle-verified ok=? ​

These effects ship a loobean as the second cell field. Loobean polarity is inverted relative to Rust: in Hoon, %.y (yes) is the atom 0 and %.n (no) is the atom 1. When you destructure a %guard-checked or %settle-verified effect, the ok= field is an atom — flip it before treating it as a Rust bool.

rust
// Effect noun: [%guard-checked hull=@ ok=@]
// ok atom == 0  →  guard passed
// ok atom == 1  →  guard rejected
let passed = ok_atom == 0;

The inversion is footgun #4 in The Four Noun Footguns below. If your decoder reads the atom and treats 1 as true, every passing guard will look like a failure and every failing guard will look like a pass.

Cell payloads — %settle-noted, %batch-flushed, %registry-updated ​

Most domain-success effects carry a structured payload. The shapes are enumerated per-graft in Effect Catalog. Examples:

EffectPayload shape
%settle-notednote=[id=@ hull=@ root=@ state=[%settled ~]]
%batch-flushedbundle=(list *) count=@ud
%registry-updated[key=@ old=* new=*]

To extract a field, descend through the noun with head/tail axis access or pattern-matched destructuring; the catalog page gives the exact shape per effect.

Unit returns from peeks — [~ ~] vs [~ [~ value]] ​

harness.peek_handle(path) collapses the standard two-level unit shape: Ok(None) for [~ ~], Ok(Some(slab)) for [~ [~ value]], and Err for bare ~ (unknown path). A subset of peeks ship as (unit (unit *)) — %settle-root, %mint-commit, %kv-value, %registry-entry, %counter-value, %log-by-seq — to distinguish "path recognized, value absent" from "path recognized, value present". Use harness.peek_raw(path) for those; see Testing → Domain Pokes → peek_raw. The Peek Catalog marks each path's return shape.

Peek-Then-Poke Gating ​

When two grafts must coordinate at the hull layer — most commonly an rbac-graft permission check before a downstream poke — the pattern is peek-then-poke:

  1. Build the peek path for the gating graft (e.g. [%rbac-has-perm pubkey perm ~]).
  2. Send the peek; receive Some(true), Some(false), or None.
  3. Branch: on Some(true) proceed with the downstream poke; on Some(false) or None skip the poke (or surface a denial from the hull driver, if you want the caller to see one).
rust
use vesl_core::{build_registry_put_poke, peek_loobean};
// build_rbac_has_perm_path is hand-rolled — see vesl-core / Driving rbac-graft
// for the full noun-slab construction. Multi-arg peek paths don't have a
// shipped builder today.

let perm_slab = build_rbac_has_perm_path(caller_pubkey, "registry-put");
let result = harness.peek_slab(perm_slab).await?;

match peek_loobean(&result) {
    Some(true) => {
        let outcome = harness.poke_slab(build_registry_put_poke(key, &record)).await?;
        // proceed — outcome is a typed vesl_core::PokeOutcome
    }
    Some(false) | None => {
        // skip: caller lacks the perm. No state change, no effect.
    }
}

The denial is silent from the kernel's perspective — the downstream poke never lands, so no %registry-error is emitted. If you want the caller to see a denial, surface one from the hull driver before returning.

peek_loobean (not a generic unit-unwrap) is the right decoder for an ok=? tail; the latter collapses atom-0 (%.y) onto the absent-value boundary. See vesl-core → Driving rbac-graft for the full hand-rolled peek-path construction and Kernel → Domain Peeks → Multi-Arg Path for the path shape.

This pattern composes: stack two peeks before a poke, or pair it with a validate-graft rule (see Common Pitfalls → Composing Three Denials).

The Four Noun Footguns ​

The four rules nock-noun-rs exists to handle. Read nock-noun-rs/README.md for the full exposition; the short list:

  • Long tags (> 8 bytes) panic at compile time under D(tas!(b"…")). Use Atom::from_bytes(slab, &Bytes::copy_from_slice(b"…")) for anything from settle-register upward.
  • Wide u64 values (hashes, IDs where the top bit may be set) panic at runtime under D(value) with Number is greater than DIRECT_MAX. Route them through atom_from_u64(slab, value).
  • AtomExt::from_bytes takes &bytes::Bytes, not &[u8] — via the nockapp::Bytes re-export.
  • Loobeans are inverted relative to Rust booleans. Hoon's %.y (yes) is atom 0; %.n (no) is atom 1. Convert at the boundary, not inline.

Hull/Kernel Drift Detection ​

Drift detection is opt-in. From a hull's build.rs, run nockup graft codegen kernel-cause-tags <PATH> after hoonc. The codegen writes kernel_cause_tags.rs to OUT_DIR and exposes its path as KERNEL_CAUSE_TAGS_PATH. Include the file in your hull and assert each cause tag at compile time:

rust
include!(env!("KERNEL_CAUSE_TAGS_PATH"));

fn build_settle_register_poke(hull: u64, root: &Tip5Hash) -> NounSlab {
    assert_kernel_cause_tag!("settle-register");
    // ... construct the noun ...
}

assert_kernel_cause_tag! runs at compile time. A kernel rename (e.g. %settle-register → %settle-write) regenerates the slice on the next cargo build; the stale "settle-register" literal in the hull then fails the membership check and cargo build halts:

text
error[E0080]: evaluation of constant value failed
  --> src/hull.rs:12:5
   |
12 |     assert_kernel_cause_tag!("settle-register");
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |     |
   |     the evaluated program panicked at 'cause tag `settle-register` not
   |     in KERNEL_CAUSE_TAGS — re-run `nockup graft codegen kernel-cause-tags`
   |     and check the driver's poke builder against the kernel's cause $%.'
   |
   = note: this error originates in the macro `assert_kernel_cause_tag`

The drift is surfaced as a compile error instead of a silent Ok(vec![]) from app.poke(...) at runtime.

KERNEL_CAUSE_TAGS is derived by parsing the +$ cause arm in the composed app.hoon. Two consequences:

  • Domain causes are covered. Inline variants you added directly to your domain (%submit-artifact, %emit-license, etc.) show up in KERNEL_CAUSE_TAGS. assert_kernel_cause_tag!("submit-artifact") compiles. Kernel rename → hull compile error, same way as graft-side renames.
  • Inactive grafts contribute nothing. A graft sitting under hoon/lib/ but never referenced from +$ cause $%(...) doesn't pollute the slice with its tags.

The default vesl template's build.rs runs the nockup-graft doctor health pass, not the cause-tag codegen; the graft-* templates wire the codegen call and surface a cargo:warning when nockup-graft is missing or codegen fails. Either way, gate include!(env!("KERNEL_CAUSE_TAGS_PATH")) with cfg(env_var = "KERNEL_CAUSE_TAGS_PATH") so the hull stays buildable when the env var is unset; the guarded include is then skipped.

Hand-Rolled Causes ​

When you have a domain cause without a builder yet, construct the noun manually:

rust
use nockapp::{AtomExt, Bytes, NockApp, noun::slab::NounSlab, wire::{SystemWire, Wire}};
use nockvm::noun::{Atom, T};
use nock_noun_rs::atom_from_u64;

async fn issue_badge(app: &mut NockApp, subject: u64) -> anyhow::Result<()> {
    let mut slab = NounSlab::new();
    let tag  = Atom::from_bytes(&mut slab, &Bytes::copy_from_slice(b"issue-badge")).as_noun();
    let subj = atom_from_u64(&mut slab, subject);
    let noun = T(&mut slab, &[tag, subj]);
    slab.set_root(noun);
    let _ = app.poke(SystemWire.to_wire(), slab).await?;
    Ok(())
}

The pattern generalizes: one atom per cause field, then T(&mut slab, &[tag, arg1, arg2, ...]).

Stuck?

Something broken? The breakage is probably already in Common Pitfalls.

See Also