diff --git a/.config/hakari.toml b/.config/hakari.toml index d814cd63..05e53654 100644 --- a/.config/hakari.toml +++ b/.config/hakari.toml @@ -30,6 +30,9 @@ platforms = [ # feature) into `workspace-hack`, which propagates into every member that # depends on `workspace-hack` regardless of feature selection. [final-excludes] +# `nexus` is the no_std kernel — it must not depend on the (std) workspace-hack. +# hakari removes the edge; `hakari verify` enforces its absence. (#279) +workspace-members = ["nexus"] third-party = [ { name = "futures-core" }, ] diff --git a/Cargo.lock b/Cargo.lock index 61bb96ae..7c5c4433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,7 +1144,6 @@ dependencies = [ "static_assertions", "thiserror", "trybuild", - "workspace-hack", ] [[package]] @@ -2649,6 +2648,7 @@ dependencies = [ "sqlx-core", "sqlx-postgres", "syn", + "thiserror", "tokio", "trybuild", "zerocopy", diff --git a/Cargo.toml b/Cargo.toml index 0d1d651f..9f2d3e2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ serde_json = "1.0.149" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "macros"] } static_assertions = "1.1.0" tempfile = "3" -thiserror = "2.0.18" +thiserror = { version = "2.0.18", default-features = false } tokio = { version = "1.52.1", features = ["full"] } tokio-stream = "0.1" diff --git a/crates/nexus-fjall/Cargo.toml b/crates/nexus-fjall/Cargo.toml index 9646b302..79992104 100644 --- a/crates/nexus-fjall/Cargo.toml +++ b/crates/nexus-fjall/Cargo.toml @@ -24,7 +24,7 @@ fjall = { workspace = true } futures.workspace = true nexus = { version = "0.1.0", path = "../nexus" } nexus-store = { version = "0.1.0", path = "../nexus-store", features = ["subscription"] } -thiserror = { workspace = true } +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["sync"] } workspace-hack = { version = "0.1", path = "../workspace-hack" } diff --git a/crates/nexus-postgres/Cargo.toml b/crates/nexus-postgres/Cargo.toml index 94254322..e82907cb 100644 --- a/crates/nexus-postgres/Cargo.toml +++ b/crates/nexus-postgres/Cargo.toml @@ -17,7 +17,7 @@ futures = { workspace = true } nexus = { version = "0.1.0", path = "../nexus" } nexus-store = { version = "0.1.0", path = "../nexus-store", features = ["subscription"] } sqlx = { workspace = true } -thiserror = { workspace = true } +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["rt", "sync"] } workspace-hack = { version = "0.1", path = "../workspace-hack" } diff --git a/crates/nexus-store/Cargo.toml b/crates/nexus-store/Cargo.toml index 6070d5b1..435deb82 100644 --- a/crates/nexus-store/Cargo.toml +++ b/crates/nexus-store/Cargo.toml @@ -59,7 +59,7 @@ parking_lot = { workspace = true, optional = true } rkyv = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } -thiserror = { workspace = true } +thiserror = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["sync"], optional = true } workspace-hack = { version = "0.1", path = "../workspace-hack" } diff --git a/crates/nexus/Cargo.toml b/crates/nexus/Cargo.toml index 03a5e060..86ad21c9 100644 --- a/crates/nexus/Cargo.toml +++ b/crates/nexus/Cargo.toml @@ -15,7 +15,6 @@ categories = ["data-structures"] arrayvec = { workspace = true } nexus-macros = { version = "0.1.0", path = "../nexus-macros", optional = true } thiserror = { workspace = true } -workspace-hack = { version = "0.1", path = "../workspace-hack" } [dev-dependencies] criterion = { workspace = true } @@ -31,7 +30,11 @@ thiserror = { workspace = true } trybuild = "1" [features] -default = [] +default = ["std"] +# Additive: enabling `std` only ADDS (the std::error::Error bridge via +# thiserror); disabling it (`--no-default-features`) yields a no_std + +# core::error::Error kernel. (#279) +std = ["thiserror/std"] derive = ["dep:nexus-macros"] testing = [] diff --git a/crates/nexus/src/aggregate.rs b/crates/nexus/src/aggregate.rs index 2abe0c7c..8904677e 100644 --- a/crates/nexus/src/aggregate.rs +++ b/crates/nexus/src/aggregate.rs @@ -3,11 +3,11 @@ use crate::event::DomainEvent; use crate::events::Events; use crate::id::Id; use crate::version::Version; -use std::error::Error; -use std::fmt; -use std::fmt::Debug; -use std::mem; -use std::num::NonZeroUsize; +use core::error::Error; +use core::fmt; +use core::fmt::Debug; +use core::mem; +use core::num::NonZeroUsize; /// State of an event-sourced aggregate. Mutated by applying domain events. /// diff --git a/crates/nexus/src/id.rs b/crates/nexus/src/id.rs index b54e2a03..ca19bf0d 100644 --- a/crates/nexus/src/id.rs +++ b/crates/nexus/src/id.rs @@ -1,5 +1,5 @@ -use std::fmt::{Debug, Display}; -use std::hash::Hash; +use core::fmt::{Debug, Display}; +use core::hash::Hash; use crate::ErrorId; diff --git a/crates/nexus/src/lib.rs b/crates/nexus/src/lib.rs index b4b64c49..06538e9d 100644 --- a/crates/nexus/src/lib.rs +++ b/crates/nexus/src/lib.rs @@ -1,3 +1,10 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +// The production kernel is pure `core` — no allocator required on-device. +// `alloc` is pulled in only for the test fixture and unit tests. +#[cfg(any(test, feature = "testing"))] +extern crate alloc; + mod aggregate; mod error; mod error_id; diff --git a/crates/nexus/src/message.rs b/crates/nexus/src/message.rs index 6506b0e0..8953d1a9 100644 --- a/crates/nexus/src/message.rs +++ b/crates/nexus/src/message.rs @@ -1,3 +1,3 @@ -use std::fmt::Debug; +use core::fmt::Debug; pub trait Message: Send + Sync + Debug + 'static {} diff --git a/crates/nexus/src/testing.rs b/crates/nexus/src/testing.rs index 3a9357b7..7ff50ab2 100644 --- a/crates/nexus/src/testing.rs +++ b/crates/nexus/src/testing.rs @@ -32,6 +32,7 @@ use crate::event::DomainEvent; use crate::events::Events; use crate::saga::{React, Saga}; use crate::version::Version; +use alloc::vec::Vec; use core::fmt::Debug; /// Entry point. Carries the id used to construct the root for replay. diff --git a/crates/nexus/src/version.rs b/crates/nexus/src/version.rs index bbc98cf5..d90b9b34 100644 --- a/crates/nexus/src/version.rs +++ b/crates/nexus/src/version.rs @@ -1,6 +1,6 @@ -use std::fmt; -use std::iter::FusedIterator; -use std::num::NonZeroU64; +use core::fmt; +use core::iter::FusedIterator; +use core::num::NonZeroU64; /// A compile-checked [`Version`] literal. /// diff --git a/crates/workspace-hack/Cargo.toml b/crates/workspace-hack/Cargo.toml index 3951af2e..49f35b51 100644 --- a/crates/workspace-hack/Cargo.toml +++ b/crates/workspace-hack/Cargo.toml @@ -30,6 +30,7 @@ sha2 = { version = "0.10" } smallvec = { version = "1", default-features = false, features = ["const_generics", "serde"] } sqlx-core = { version = "0.8", features = ["_rt-tokio", "any", "json", "migrate", "offline"] } sqlx-postgres = { version = "0.8", default-features = false, features = ["migrate", "offline"] } +thiserror = { version = "2" } tokio = { version = "1", features = ["full"] } trybuild = { version = "1", default-features = false, features = ["diff"] } zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } @@ -55,6 +56,7 @@ smallvec = { version = "1", default-features = false, features = ["const_generic sqlx-core = { version = "0.8", features = ["_rt-tokio", "any", "json", "migrate", "offline"] } sqlx-postgres = { version = "0.8", default-features = false, features = ["migrate", "offline"] } syn = { version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +thiserror = { version = "2" } tokio = { version = "1", features = ["full"] } trybuild = { version = "1", default-features = false, features = ["diff"] } zerocopy = { version = "0.8", default-features = false, features = ["derive", "simd"] } diff --git a/docs/superpowers/plans/2026-07-06-no-std-kernel.md b/docs/superpowers/plans/2026-07-06-no-std-kernel.md new file mode 100644 index 00000000..9c77999f --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-no-std-kernel.md @@ -0,0 +1,472 @@ +# no_std Kernel Port (#279) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `crates/nexus` (the kernel) build for `no_std` targets (bare-metal ARM + WASM) behind an additive `std` default feature, and move its public error bounds from `std::error::Error` to `core::error::Error` before the 1.0 freeze. + +**Architecture:** The kernel is already heap-free in production and depends only on `arrayvec` + `thiserror` (both no_std-capable). The port is: add `#![cfg_attr(not(feature = "std"), no_std)]`, swap `std::` → `core::` in 4 files, make `thiserror` no_std at the workspace, drop the `workspace-hack` edge from `nexus` (it drags std in), and add two `nix flake check` gates that build the kernel for `thumbv7em-none-eabihf` + `wasm32-unknown-unknown`. Nothing outside `crates/nexus` changes behavior; store/adapter no_std is out of scope (#300/#301/#302). + +**Tech Stack:** Rust 2024 (pinned stable 1.95), `fenix` toolchain via `rust-toolchain.toml`, `crane` flake checks, `cargo-hakari` (workspace-hack), `thiserror` 2.0. + +**Spec:** `docs/superpowers/specs/2026-07-06-no-std-kernel-design.md` + +**Working branch:** `feat/279-no-std-kernel` (already created off `origin/main`). + +> **Conventions for every commit below:** +> - The pre-commit hook runs `nix flake check` automatically — **do not** run the full gate by hand first. Just `git commit`; a red gate blocks the commit. +> - Run all cargo/tooling through the dev shell: `nix develop -c `. +> - Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. +> - The current Nix system attribute is `aarch64-darwin` (from `nix flake check` output). Use it in `nix build .#checks.aarch64-darwin.` commands. + +--- + +## Task 1: Add cross-compile targets and establish the failing no_std baseline + +**Files:** +- Modify: `rust-toolchain.toml` + +- [ ] **Step 1: Add the two targets to the pinned toolchain** + +Replace the `[toolchain]` block in `rust-toolchain.toml` so it reads exactly: + +```toml +[toolchain] +channel = "1.95.0" +components = ["cargo", "rustc", "rust-std", "rustfmt", "clippy", "llvm-tools-preview"] +targets = ["wasm32-unknown-unknown", "thumbv7em-none-eabihf"] +``` + +(Only the `targets = [...]` line is new. `fenix`'s `fromToolchainFile` honors this field; the `sha256` in `flake.nix` is a channel-manifest hash and does **not** change — proven by the sibling `cesr` crate, which pins the identical channel + sha256 and adds `wasm32-unknown-unknown` the same way.) + +- [ ] **Step 2: Confirm the target resolves and the no_std build is currently RED** + +Run: `nix develop -c cargo build -p nexus --no-default-features --target thumbv7em-none-eabihf` + +Expected: **FAIL** with errors like `` error[E0463]: can't find crate for `std` `` (the kernel still implicitly links std — `lib.rs` is not yet `#![no_std]`). This is the red baseline the port turns green. + +> If instead you see `error: "thumbv7em-none-eabihf" may not be installed` or `can't find crate for `core``, the target's `rust-std` was not fetched — re-check the `targets` line in `rust-toolchain.toml` and re-enter the shell (`nix develop`). + +- [ ] **Step 3: Commit** + +```bash +git add rust-toolchain.toml +git commit -m "build(nexus): add wasm32 + thumbv7em targets to pinned toolchain (#279) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +Expected: pre-commit `nix flake check` passes (the std build is unaffected by adding targets), commit succeeds. + +--- + +## Task 2: Make `thiserror` no_std at the workspace and add the `std` feature to `nexus` + +**Files:** +- Modify: `Cargo.toml` (root, line 42) +- Modify: `crates/nexus/Cargo.toml` (`[features]`) +- Modify: `crates/nexus-store/Cargo.toml` (line 62) +- Modify: `crates/nexus-fjall/Cargo.toml` (line 27) +- Modify: `crates/nexus-postgres/Cargo.toml` (line 20) + +**Why the ripple:** Cargo workspace inheritance cannot override `default-features` (only `optional`/`features` are inheritable). To let the kernel take `thiserror` without std, the *workspace* declaration must be `default-features = false`; the three std crates then opt back into std explicitly so their behavior is unchanged. + +- [ ] **Step 1: Turn off `thiserror` default features at the workspace** + +In root `Cargo.toml`, change line 42 from: + +```toml +thiserror = "2.0.18" +``` + +to: + +```toml +thiserror = { version = "2.0.18", default-features = false } +``` + +- [ ] **Step 2: Give `nexus` an additive `std` feature that re-enables `thiserror/std`** + +In `crates/nexus/Cargo.toml`, replace the `[features]` block: + +```toml +[features] +default = [] +derive = ["dep:nexus-macros"] +testing = [] +``` + +with: + +```toml +[features] +default = ["std"] +# Additive: enabling `std` only ADDS (the std::error::Error bridge via thiserror); +# disabling it (`--no-default-features`) yields a no_std + core::error::Error kernel. +std = ["thiserror/std"] +derive = ["dep:nexus-macros"] +testing = [] +``` + +- [ ] **Step 3: Keep the three std crates on std (behavior-preserving)** + +In each of `crates/nexus-store/Cargo.toml` (line 62), `crates/nexus-fjall/Cargo.toml` (line 27), `crates/nexus-postgres/Cargo.toml` (line 20), change: + +```toml +thiserror = { workspace = true } +``` + +to: + +```toml +thiserror = { workspace = true, features = ["std"] } +``` + +- [ ] **Step 4: Verify the whole workspace still builds on std** + +Run: `nix develop -c cargo build --workspace` + +Expected: **PASS** (no behavior change; every std crate still has `thiserror/std`). + +- [ ] **Step 5: Commit** + +```bash +git add Cargo.toml crates/nexus/Cargo.toml crates/nexus-store/Cargo.toml crates/nexus-fjall/Cargo.toml crates/nexus-postgres/Cargo.toml +git commit -m "build: make thiserror no_std at workspace, add nexus std feature (#279) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +Expected: pre-commit `nix flake check` passes, commit succeeds. + +--- + +## Task 3: Drop the `workspace-hack` edge from `nexus` + +**Files:** +- Modify: `.config/hakari.toml` +- Modify (by tooling): `crates/nexus/Cargo.toml`, `crates/workspace-hack/Cargo.toml` + +**Why:** `workspace-hack` unifies std deps (tokio, sqlx) and every member depends on it — that would drag std into the no_std kernel build. hakari's `final-excludes` removes the edge and the `hakari verify` gate then enforces its *absence*. + +- [ ] **Step 1: Exclude `nexus` from workspace-hack** + +In `.config/hakari.toml`, change the existing `[final-excludes]` table from: + +```toml +[final-excludes] +third-party = [ + { name = "futures-core" }, +] +``` + +to: + +```toml +[final-excludes] +# `nexus` is the no_std kernel — it must not depend on the (std) workspace-hack. +# hakari removes the edge; `hakari verify` enforces its absence. (#279) +workspace-members = ["nexus"] +third-party = [ + { name = "futures-core" }, +] +``` + +- [ ] **Step 2: Regenerate and apply** + +Run: `nix develop -c cargo hakari generate` +Then: `nix develop -c cargo hakari manage-deps` + +(`generate` rewrites `crates/workspace-hack/Cargo.toml` without `nexus`'s contributions; `manage-deps` removes the `workspace-hack` line from `crates/nexus/Cargo.toml`.) + +- [ ] **Step 3: Verify hakari is consistent and `nexus` no longer depends on workspace-hack** + +Run: `nix develop -c cargo hakari verify` +Expected: **PASS** (exit 0). + +Run: `grep -n "workspace-hack" crates/nexus/Cargo.toml` +Expected: **no output** (the dependency line is gone). + +- [ ] **Step 4: Verify the std build still works** + +Run: `nix develop -c cargo build -p nexus` +Expected: **PASS**. + +- [ ] **Step 5: Commit** + +```bash +git add .config/hakari.toml crates/nexus/Cargo.toml crates/workspace-hack/Cargo.toml +git commit -m "build(nexus): exclude kernel from workspace-hack for no_std (#279) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +Expected: pre-commit `nix flake check` passes (`nexus-hakari` check green), commit succeeds. + +--- + +## Task 4: Port the kernel to no_std (the red → green step) + +**Files:** +- Modify: `crates/nexus/src/lib.rs` +- Modify: `crates/nexus/src/aggregate.rs:6-10` +- Modify: `crates/nexus/src/message.rs:1` +- Modify: `crates/nexus/src/id.rs:1-2` +- Modify: `crates/nexus/src/version.rs:1-3` +- Modify: `crates/nexus/src/testing.rs` (add one import) + +- [ ] **Step 1: Make `lib.rs` no_std with allocator-free production code** + +At the very top of `crates/nexus/src/lib.rs` (before the `mod` declarations), add: + +```rust +#![cfg_attr(not(feature = "std"), no_std)] + +// The production kernel is pure `core` — no allocator required on-device. +// `alloc` is pulled in only for the test fixture and unit tests. +#[cfg(any(test, feature = "testing"))] +extern crate alloc; +``` + +- [ ] **Step 2: Swap `std::` → `core::` in `aggregate.rs`** + +In `crates/nexus/src/aggregate.rs`, change lines 6–10 from: + +```rust +use std::error::Error; +use std::fmt; +use std::fmt::Debug; +use std::mem; +use std::num::NonZeroUsize; +``` + +to: + +```rust +use core::error::Error; +use core::fmt; +use core::fmt::Debug; +use core::mem; +use core::num::NonZeroUsize; +``` + +(The `std::` occurrences remaining at lines ~92/100/149 are doctests and ~449/606 are `#[cfg(test)]` code — leave them; they compile as std test binaries and never enter the no_std build.) + +- [ ] **Step 3: Swap `std::` → `core::` in `message.rs`** + +In `crates/nexus/src/message.rs`, change line 1 from: + +```rust +use std::fmt::Debug; +``` + +to: + +```rust +use core::fmt::Debug; +``` + +- [ ] **Step 4: Swap `std::` → `core::` in `id.rs`** + +In `crates/nexus/src/id.rs`, change lines 1–2 from: + +```rust +use std::fmt::{Debug, Display}; +use std::hash::Hash; +``` + +to: + +```rust +use core::fmt::{Debug, Display}; +use core::hash::Hash; +``` + +- [ ] **Step 5: Swap `std::` → `core::` in `version.rs`** + +In `crates/nexus/src/version.rs`, change lines 1–3 from: + +```rust +use std::fmt; +use std::iter::FusedIterator; +use std::num::NonZeroU64; +``` + +to: + +```rust +use core::fmt; +use core::iter::FusedIterator; +use core::num::NonZeroU64; +``` + +- [ ] **Step 6: Point `testing.rs` at `alloc::vec::Vec`** + +In `crates/nexus/src/testing.rs`, add this import at the top of the file with the other `use` statements (the fixture uses `Vec` / `Vec::new()` in ~10 places; a single import resolves them all): + +```rust +use alloc::vec::Vec; +``` + +- [ ] **Step 7: Verify no stray production `std::` remains in the ported files** + +Run: + +```bash +nix develop -c bash -c "grep -n 'std::' crates/nexus/src/{aggregate,message,id,version}.rs | grep -vE '///|impl std::fmt::Display for Ctr|use std::panic'" +``` + +Expected: only doctest lines (prefixed `///`) — no bare production `use std::` or inline `std::` outside `#[cfg(test)]`. If a production line appears, swap its `std::` → `core::`. + +- [ ] **Step 8: Verify the std build + tests are unchanged** + +Run: `nix develop -c cargo build -p nexus --all-features` +Expected: **PASS**. + +Run: `nix develop -c cargo test -p nexus --features testing,derive` +Expected: **PASS** (all existing kernel tests green — proves the `core::` swap is semantics-preserving). + +- [ ] **Step 9: Verify the no_std builds are now GREEN (red → green)** + +Run: `nix develop -c cargo build -p nexus --no-default-features --target thumbv7em-none-eabihf` +Expected: **PASS** (was FAIL in Task 1 Step 2). + +Run: `nix develop -c cargo build -p nexus --no-default-features --target wasm32-unknown-unknown` +Expected: **PASS**. + +- [ ] **Step 10: Commit** + +```bash +git add crates/nexus/src/lib.rs crates/nexus/src/aggregate.rs crates/nexus/src/message.rs crates/nexus/src/id.rs crates/nexus/src/version.rs crates/nexus/src/testing.rs +git commit -m "feat(nexus)!: port kernel to no_std, error bounds to core::error::Error (#279) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +Expected: pre-commit `nix flake check` passes, commit succeeds. + +--- + +## Task 5: Wire the two no_std builds into `nix flake check` as permanent gates + +**Files:** +- Modify: `flake.nix` (add two entries to the `checks` attrset) + +- [ ] **Step 1: Add `nexus-wasm` and `nexus-nostd` checks** + +In `flake.nix`, inside the `checks = { ... }` attrset (e.g. right after the `nexus-hakari` check and before the closing `};` of `checks`), add: + +```nix + # no_std gates — CI is just `nix flake check`, so these ride along. + # `nexus-nostd` (thumbv7em-none-eabihf) is the STRONG gate: a fully + # std-free bare-metal target. `wasm32-unknown-unknown` still ships std, + # so it alone would not catch a std leak. Both build --no-default-features. + nexus-wasm = craneLib.mkCargoDerivation (commonArgs // { + inherit cargoArtifacts; + pname = "nexus-wasm"; + buildPhaseCargoCommand = '' + cargo build -p nexus --target wasm32-unknown-unknown --no-default-features + ''; + }); + + nexus-nostd = craneLib.mkCargoDerivation (commonArgs // { + inherit cargoArtifacts; + pname = "nexus-nostd"; + buildPhaseCargoCommand = '' + cargo build -p nexus --target thumbv7em-none-eabihf --no-default-features + ''; + }); +``` + +- [ ] **Step 2: Build both new checks directly** + +Run: `nix build .#checks.aarch64-darwin.nexus-nostd .#checks.aarch64-darwin.nexus-wasm -L` + +Expected: **both build successfully** (no `std` errors). + +> If a check errors that `cargoArtifacts` lacks the target, that is fine to debug by confirming Task 4 Step 9 still passes in the dev shell — the crane derivation runs the identical `cargo build` command. + +- [ ] **Step 3: Commit** + +```bash +git add flake.nix +git commit -m "ci(nexus): gate no_std kernel build on thumbv7em + wasm32 (#279) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +Expected: pre-commit `nix flake check` now includes `nexus-wasm` + `nexus-nostd` and passes. + +--- + +## Task 6: Confirm the README claim and the macro output; final acceptance + +**Files:** +- Verify (adjust only if inaccurate): `README.md` +- Verify only: `crates/nexus-macros/src/lib.rs` + +- [ ] **Step 1: Confirm the embedded/WASM README claim is present and accurate** + +Run: `grep -in "embedded\|wasm\|no_std\|no-std\|zero-std\|allocation" README.md` + +Expected: the paragraph claiming the kernel compiles for embedded/WASM exists. It is now CI-backed, so **keep it**. Only if the wording overstates scope (e.g. implies the *store* is no_std) trim it to the kernel — for example ensure it reads as "the **kernel** compiles for embedded and WASM targets," not the whole framework. If you edit, `git add README.md`. + +- [ ] **Step 2: Confirm the `#[nexus::aggregate]` macro emits no `std::` paths** + +Run: `grep -n "std ::\|std::" crates/nexus-macros/src/lib.rs | grep -v "use std::collections"` + +Expected: **no `std::` inside `quote!` output** (the only `use std::collections` is the macro's own host-side code; generated code already uses `::core::` / `::nexus::`). If any generated path uses `std::`, change it to `::core::`. (The `DomainEvent` and `transforms` macros were already verified `::core::`-clean during design.) + +- [ ] **Step 3: Final acceptance — full gate** + +The pre-commit hook has run `nix flake check` on every commit, so the gate is already green including the two no_std checks. Confirm the acceptance criteria from #279 are met: + +- [ ] Public error trait bounds are `core::error::Error` (Task 4 Step 2 — `aggregate.rs` `use core::error::Error`). +- [ ] `nix flake check` builds the kernel no_std (`thumbv7em-none-eabihf`) + `wasm32-unknown-unknown` (Task 5). +- [ ] README embedded/WASM claim retained and CI-backed (Step 1). + +- [ ] **Step 4: Commit any README adjustment (skip if none)** + +```bash +git add README.md +git commit -m "docs(nexus): scope embedded/WASM claim to the kernel, now CI-backed (#279) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Open the pull request + +- [ ] **Step 1: Push and open the PR** + +```bash +git push -u origin feat/279-no-std-kernel +gh pr create --title "feat(nexus)!: port kernel to no_std (core+alloc) (#279)" --body "$(cat <<'EOF' +Closes #279. + +Ports `crates/nexus` to `no_std` behind an additive `std` default feature, and moves public error bounds from `std::error::Error` to `core::error::Error` (freeze-relevant — breaking to do after 1.0). + +## What +- `#![cfg_attr(not(feature = "std"), no_std)]`; production kernel is pure `core` (no allocator), `alloc` only under `test`/`testing`. +- `std::` → `core::` in `aggregate.rs`, `message.rs`, `id.rs`, `version.rs`. +- `thiserror` no_std at the workspace; std crates opt back in via `features = ["std"]`. +- `nexus` excluded from `workspace-hack` (hakari `final-excludes`) so no std leaks in. +- Toolchain gains `wasm32-unknown-unknown` + `thumbv7em-none-eabihf`; two `nix flake check` gates build the kernel `--no-default-features` for both. + +## Scope +Kernel only. Store no_std is tracked as #300 → #301 → #302. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR opens against `main` under the `joeldsouzax` account. + +--- + +## Notes for the executor + +- **Empirical check flagged in the spec:** the three std crates get `thiserror` `features = ["std"]` conservatively (behavior-preserving). Since Rust 1.81 `std::error::Error` *is* `core::error::Error`, they may compile without it — but do **not** remove it unless you verify the build stays green; keeping it is zero-risk. +- **Do not** merge to `main` directly (squash-only via PR; signed commits required). The PR is the handoff point. +- If any `nix flake check` derivation fails on `aarch64-linux`/`x86_64-*`, note it — the local gate only checks the current system (`aarch64-darwin`); CI checks all. diff --git a/docs/superpowers/specs/2026-07-06-no-std-kernel-design.md b/docs/superpowers/specs/2026-07-06-no-std-kernel-design.md new file mode 100644 index 00000000..0a23d8e0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-no-std-kernel-design.md @@ -0,0 +1,154 @@ +# Design: `no_std` kernel port (#279) + +**Issue:** [#279](https://github.com/devrandom-labs/nexus/issues/279) — `[freeze][T1] no_std kernel: port `nexus` to core+alloc` +**Milestone:** 1 — Nexus: Pre-Freeze (1.0 blockers) +**Scope:** `crates/nexus` only. Store/adapter no_std is out of scope (tracked as #300 → #301 → #302). +**Decision:** Option A (port the kernel) — chosen over Option B (drop the IoT claim). Aligns with the IoT/mobile-first target and unblocks Bombay-SDK #7. + +--- + +## 1. Background — verified current state + +The port is mechanical because the kernel is already heap- and std-light. Verified during design: + +- **Production kernel code has no heap use** — every `Vec`/`vec!`/`Box`/`String` is in `#[cfg(test)]` code or the `testing` fixture. Dependencies are `arrayvec` (already `default-features = false`, no_std) and `thiserror` (no_std-capable via `default-features = false`). +- **The std sweep touches only 4 files** — `aggregate.rs`, `message.rs`, `id.rs`, `version.rs`. `error.rs`, `error_id.rs`, `event.rs`, `events.rs`, `saga.rs`, `closing_the_books.rs` have **zero** non-doc `std::` usage. +- **The freeze-relevant symbol is `std::error::Error`** in `aggregate.rs`'s public trait bounds. Switching to `core::error::Error` (stable since Rust 1.81, under our 1.95 MSRV) after 1.0 would be a breaking change — hence pre-freeze. +- **`nexus-macros` generated code is already `::core::`-clean** (`::core::option::Option`, `::core::result::Result`, `::nexus::…`). Its own `use std::collections` is host-side proc-macro code, not emitted output. *(Verify the `#[nexus::aggregate]` output too during implementation.)* +- **Toolchain mechanism is proven in-repo** — the sibling `cesr` crate pins the *identical* `1.95.0` channel with the *identical* fenix `sha256` and adds `targets = ["wasm32-unknown-unknown"]` to its `rust-toolchain.toml`; its `cesr-wasm` flake check builds `--target wasm32-unknown-unknown`. fenix's `fromToolchainFile` honors the toolchain file's `targets` field, and the `sha256` (channel-manifest hash) is unchanged by adding targets. + +## 2. Design + +### 2.1 Feature architecture — additive `std`, default-on + +```toml +# crates/nexus/Cargo.toml +[features] +default = ["std"] +std = ["thiserror/std"] # additive: enabling only adds; never removes API +derive = ["dep:nexus-macros"] +testing = [] +``` + +`std` is additive and on by default, so **every existing build is byte-for-byte unchanged** — `cargo build`, the `nix flake check` nextest run, examples, and downstream consumers all keep std. The no_std path is reached only via `--no-default-features`. + +### 2.2 `lib.rs` + +```rust +#![cfg_attr(not(feature = "std"), no_std)] + +// Production kernel is pure `core` (no allocator required on-device). +// `alloc` is needed only by the test fixture and unit tests. +#[cfg(any(test, feature = "testing"))] +extern crate alloc; +``` + +Gating `alloc` behind `test`/`testing` states and enforces a stronger property: **the production kernel needs no allocator at all** — a meaningful IoT guarantee. + +### 2.3 `std::` → `core::` sweep (4 files) + +| From | To | Files | +|------|----|-------| +| `std::error::Error` | `core::error::Error` | `aggregate.rs` (**freeze-relevant public bound**) | +| `std::fmt`, `Debug`, `Display` | `core::fmt` | `message.rs`, `id.rs`, `version.rs`, `aggregate.rs` | +| `std::hash::Hash` | `core::hash::Hash` | `id.rs` | +| `std::num::NonZero*` | `core::num::NonZero*` | `version.rs`, `aggregate.rs` | +| `std::iter::FusedIterator`, `std::mem` | `core::iter`, `core::mem` | `version.rs`, `aggregate.rs` | + +Doctests may keep `use std::…` — they compile as separate std test binaries and never enter the no_std build (the flake gate is `cargo build`, not doctest, on the no_std targets). + +### 2.4 `testing.rs` + +`Vec` → `alloc::vec::Vec`. The fixture stays no_std+alloc-compatible. + +### 2.5 `thiserror` — no_std, with a behavior-preserving ripple + +Cargo **workspace dependency inheritance cannot override `default-features`** (verified against the Cargo reference — only `optional` and `features` are inheritable). So to make the kernel's `thiserror` no_std: + +- Root `Cargo.toml`: `thiserror = { version = "2.0.18", default-features = false }`. +- `nexus`: `std = ["thiserror/std"]` (already above); its dep stays `thiserror = { workspace = true }`. +- `nexus-store`, `nexus-fjall`, `nexus-postgres`: add `features = ["std"]` to their `thiserror` dep **to preserve today's behavior exactly**. + +This is a necessary, behavior-preserving cross-crate change even though the card is "kernel-only." *Verification note:* since Rust 1.81 `std::error::Error` **is** `core::error::Error` (a re-export), the std crates may compile unchanged without `features = ["std"]`; add it only where dropping it actually breaks a build (measure, don't assume). + +### 2.6 `rust-toolchain.toml` + +```toml +targets = ["wasm32-unknown-unknown", "thumbv7em-none-eabihf"] +``` + +`sha256` in `flake.nix` is unchanged (channel-manifest hash; cesr proves adding a target doesn't move it). rustup users transparently gain the targets too. + +### 2.7 `flake.nix` — two checks (CI = `nix flake check`, nothing else) + +Mirror the `cesr-wasm` / `postgresTests` crane pattern. Both are `checks` entries, so a plain `nix flake check` runs them — no CI-only steps. + +```nix +nexus-wasm = craneLib.mkCargoDerivation (commonArgs // { + inherit cargoArtifacts; + pname = "nexus-wasm"; + buildPhaseCargoCommand = '' + cargo build -p nexus --target wasm32-unknown-unknown --no-default-features + ''; +}); +nexus-nostd = craneLib.mkCargoDerivation (commonArgs // { + inherit cargoArtifacts; + pname = "nexus-nostd"; + buildPhaseCargoCommand = '' + cargo build -p nexus --target thumbv7em-none-eabihf --no-default-features + ''; +}); +``` + +`thumbv7em-none-eabihf` is the **strong** gate: a truly std-free Cortex-M4F target (wasm32-unknown-unknown still ships std, so it alone would not catch a std leak). Since the production kernel is pure `core` (no `alloc` in the default/no-feature build), no `#[global_allocator]` is needed for this build. + +### 2.8 `workspace-hack` — hakari `final-excludes` + +`workspace-hack` unifies std deps (tokio, sqlx) and every crate depends on it, which would drag std into the no_std build. Fix (authoritative, per hakari docs): + +```toml +# .config/hakari.toml +[final-excludes] +workspace-members = ["nexus"] +third-party = [ { name = "futures-core" } ] # existing +``` + +`cargo hakari manage-deps` then **removes** the `workspace-hack` edge from `nexus`, and `hakari verify` enforces its *absence* — the gate stays green. The kernel's dependency closure becomes just `arrayvec` + `thiserror` (+ the host-only `nexus-macros`). + +### 2.9 README + +Keep the embedded/WASM paragraph — now backed by the green `nexus-wasm` + `nexus-nostd` flake checks rather than aspirational. + +## 3. Testing strategy + +This is a build/toolchain + type-bound change, so the primary test **is the build itself**: + +1. **The no_std build gates** (`nexus-wasm`, `nexus-nostd`) — these fail if any std leaks into the kernel. This is the defensive-boundary test for the no_std contract. +2. **Existing kernel test suite** — runs unchanged on the std default path via `nix flake check` nextest (sequence/lifecycle/property tests for aggregate/saga/events already exist). No behavior change is expected; a green suite proves the `core::` swap is semantics-preserving. +3. **`trybuild` / doctest** — unchanged; compile on std. + +No new runtime tests are required — there is no new runtime behavior, only a narrowed dependency surface and a bound change (`std::error::Error` → `core::error::Error`, which is the same trait since 1.81). + +## 4. Risks & verification (measure, don't assume) + +- [ ] `thiserror` no_std actually compiles the kernel error types under `--no-default-features` (thiserror 2.0.18 has exactly one feature, `std`, default-on — verified via docs.rs). +- [ ] `#[nexus::aggregate]` macro output is `::core::`-clean (DomainEvent/transforms already are). +- [ ] The two flake checks pass — proves no accidental std usage remains. +- [ ] `hakari verify` passes with `nexus` in `final-excludes`. +- [ ] Whether std crates need explicit `thiserror` `features = ["std"]` (may be a no-op post-1.81). + +## 5. Acceptance (from #279) + +- [ ] Public error trait bounds are `core::error::Error`. +- [ ] `nix flake check` builds the kernel no_std (`thumbv7em-none-eabihf`) + `wasm32-unknown-unknown`. +- [ ] README embedded/WASM claim retained and now CI-backed. + +## 6. Out of scope + +Full store no_std is a separate arc, tracked as pre-freeze follow-ups: + +- **#300** — extract `StreamNotifiers` → `nexus-wake` (tokio out of `nexus-store`). +- **#301** — port `nexus-store` to no_std + alloc; store public error bounds → `core::error::Error`. +- **#302** — no_std `WakeSource` bridge for on-device live-tail subscriptions. + +Key finding underpinning that split: `nexus-store` production code is already no_std+alloc-clean **except** for tokio, which is confined to `notify.rs` behind the `subscription` feature and abstracted behind the `WakeSource` trait. diff --git a/flake.nix b/flake.nix index 3c104f19..bf658e67 100644 --- a/flake.nix +++ b/flake.nix @@ -126,6 +126,26 @@ ''; nativeBuildInputs = [ cargo-hakari ]; }; + + # no_std gates — CI is just `nix flake check`, so these ride along. + # `nexus-nostd` (thumbv7em-none-eabihf) is the STRONG gate: a fully + # std-free bare-metal target. `wasm32-unknown-unknown` still ships std, + # so it alone would not catch a std leak. Both build --no-default-features. + nexus-wasm = craneLib.mkCargoDerivation (commonArgs // { + inherit cargoArtifacts; + pname = "nexus-wasm"; + buildPhaseCargoCommand = '' + cargo build -p nexus --target wasm32-unknown-unknown --no-default-features + ''; + }); + + nexus-nostd = craneLib.mkCargoDerivation (commonArgs // { + inherit cargoArtifacts; + pname = "nexus-nostd"; + buildPhaseCargoCommand = '' + cargo build -p nexus --target thumbv7em-none-eabihf --no-default-features + ''; + }); }; packages = { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4ac9f938..8124fb36 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -7,3 +7,4 @@ [toolchain] channel = "1.95.0" components = ["cargo", "rustc", "rust-std", "rustfmt", "clippy", "llvm-tools-preview"] +targets = ["wasm32-unknown-unknown", "thumbv7em-none-eabihf"]