# r8r: Rust port of n8n.io

## Context

Tanner Woodrum is an in-flight cofounder conversation (active as of 2026-04-24, see `project_potential_cofounder.md`). He has not responded to outreach messages and Michael will not message him again. The play is to **build a working artifact and hand it to him directly** as the recruitment vehicle. The artifact is a Rust port of n8n.io scoped at parity-on-name with all 306 nodes, parity-on-function with the ~50 nodes federal/operator buyers actually use.

The strategic angle is real beyond the recruitment: n8n is a venture-funded incumbent (~$300M valuation, ~$60M raised) shipping a 300MB Node monolith under the fair-code Sustainable Use License. A 5MB static Rust binary under Unlicense, air-gap-deployable, no SaaS callback, no Node runtime in production - lands exactly on Cochran Block's CISA Secure-by-Design / EO 14028 thesis and on Michael's existing federal go-to-market motion.

Scope explicitly excludes T3 (the long tail of operation implementations): all 306 nodes will exist as registrable types, ~50 will fully execute, ~250 will return `Err("operation not yet ported")` with a pointer to the upstream TS file. Tanner's natural co-build territory is the codegen pipeline and the T3 fill-in - clean ownership split that gifts him real surface from line 1.

## License & legal posture

n8n is licensed under the Sustainable Use License - **we cannot copy code**. We can:
- Clone the API surface (node names, parameter names, type names) - not copyrightable
- Mirror behavior - not copyrightable
- Extract metadata (the JSON-shaped `description` blocks) for codegen targeting

We cannot:
- Copy `execute()` bodies or any implementation TypeScript
- Vendor any portion of n8n's source into our repo

The codegen extractor reads n8n source from a separate clone path (`/home/mcochran/n8n`) and emits Rust skeletons. Generated code carries our own license headers, never derived strings from n8n source beyond the metadata schema.

Repo license: **Unlicense** (matches Cochran Block ecosystem, federal-friendly, public domain dedication). Headers per option 3:

```rust
// r8r: rust port of n8n.io
// Initial author: Michael Cochran <mcochran@cochranblock.org>
// Built for: Tanner Woodrum, intended cofounder
// License: Unlicense (public domain dedication, see UNLICENSE)
```

## GitHub repo

- Owner: `cochranblock` (the user's existing SSH-keyed GitHub account)
- Name: `r8r`
- Visibility: **private** (gift-shaped delivery, flip to public later)
- No initial README/LICENSE from GitHub - we provide both

Creation path (no `gh` CLI installed on this node):
1. Install `gh` static binary to `~/bin/gh` (~5MB download, no sudo needed) OR
2. Use existing SSH credential to `git push` to a manually-created repo

The cleaner path is option 1 - install gh once, get repo creation + future PR ergonomics. SSH already works (`git@github.com` returns "Hi cochranblock!").

## Workspace layout

Local path: `/home/mcochran/r8r` (mirrors fleet.toml convention of project dirs at `~/`).

```
r8r/
├── PLAN.md                         # this plan, copied in
├── README.md                       # Tanner-addressed, "here's what's yours"
├── UNLICENSE                       # standard Unlicense text
├── Cargo.toml                      # workspace root
├── .gitignore                      # rust + node_modules (codegen reads node_modules)
├── .cursor/rules/
│   └── rough-draft-exopack.mdc     # copied from cochranblock
├── crates/
│   ├── r8r-core/            # types, expression eval, TOML loader
│   ├── r8r-engine/          # executor, scheduler, retry, queue+waiting-queue
│   ├── r8r-nodes/           # 50 hand-built + 250 codegen'd
│   ├── r8r-codegen/         # TS-AST extractor, emits flow-nodes/generated/*.rs
│   └── r8r-cli/             # clap binary, root for both bin targets
├── examples/
│   ├── webhook-to-slack.toml       # the boring n8n parity demo
│   ├── vcenter-rebuild.toml        # Michael's moat - hand-rolled vcenter client as a node
│   └── self-deploy.toml            # the recursive one - workflow that ships itself
├── nodes-spec/                     # JSON output of codegen extraction (300+ files)
└── tests/
    ├── triple_sims_smoke.rs
    └── parity_*.rs
```

### Crate dependency graph

```
r8r-cli  ──┬── r8r-engine ──┬── r8r-core
                  ├── r8r-nodes ───┤
                  └── r8r-codegen ──┘ (build-time only)
```

`r8r-codegen` is a `[build-dependencies]` crate that runs in `build.rs` of `r8r-nodes`, OR a separate binary you run manually (`cargo run -p r8r-codegen -- /path/to/n8n`). Choose the manual path for v0.1 - faster iteration, no build-time toolchain coupling.

## Engine architecture (synthesized from Agent 1 exploration)

### Core types (`r8r-core`)

Mirror n8n's five load-bearing structures:

| n8n TS type | Rust equivalent | Shape |
|---|---|---|
| `INodeExecutionData` | `NodeItem` | `{ json: serde_json::Value, binary: Option<HashMap<String, Binary>>, error: Option<NodeError>, paired_item: Option<PairedItem>, metadata: Option<ItemMetadata> }` |
| `ITaskData` | `TaskRun` | `{ start_time, execution_time, status: enum {Success, Error, Waiting}, data: TaskConnections, error: Option<ExecutionError>, hints: Vec<Hint> }` |
| `IExecuteData` | `ExecuteFrame` | `{ node: NodeRef, data: TaskConnections, source: Option<TaskConnectionsSource>, run_index: u32 }` (queue element) |
| `IRunExecutionData` | `RunState` | `{ stack: VecDeque<ExecuteFrame>, waiting: HashMap<(NodeName, RunIndex), WaitingState>, run_data: HashMap<NodeName, Vec<TaskRun>>, last_node_executed: Option<NodeName> }` |
| `IConnection` | `Edge` | `{ node: NodeName, kind: ConnectionKind, index: u16 }` |

### Executor (`r8r-engine::executor`)

Algorithm (single-threaded, async via tokio for I/O nodes):

```
pub async fn execute(workflow: &Workflow, trigger_data: NodeOutput) -> RunState {
    let mut state = RunState::new();
    state.stack.push_front(initial_frame(workflow, trigger_data));
    
    while let Some(frame) = state.stack.pop_front() {
        let node = workflow.nodes.get(&frame.node)?;
        let node_type = registry::lookup(&node.type_name)?;
        
        match node_type.execute(&frame, &state).await {
            Ok(outputs) => propagate(outputs, &workflow, &mut state),
            Err(e) if node.continue_on_fail => propagate_pass_through(&frame, &mut state),
            Err(e) if node.on_error == OnError::ContinueErrorOutput => route_to_error_output(e, &mut state),
            Err(e) => { state.error = Some(e); return state; }
        }
    }
    state
}

fn propagate(outputs: NodeOutput, workflow: &Workflow, state: &mut RunState) {
    for (output_index, items) in outputs.iter().enumerate() {
        for edge in workflow.edges_out(node, output_index) {
            let target = workflow.nodes.get(&edge.node)?;
            if target.input_count > 1 {
                // multi-input: park in waiting queue keyed by (target_name, run_index)
                state.waiting.entry((target.name, run_index))
                    .or_default()
                    .add(edge.index, items);
                if state.waiting.get(&key).is_complete() {
                    state.stack.push_back(make_frame_from_waiting(...));
                }
            } else {
                state.stack.push_back(ExecuteFrame::new(target, items));
            }
        }
    }
}
```

Parallel branches happen naturally - when a node emits `outputs[0]` and `outputs[1]`, both target chains get pushed to the queue and executed in order. True parallelism within a single-input chain is handled by the async runtime when `node_type.execute` yields on I/O.

### Expression evaluator (`r8r-core::expr`)

n8n's expression engine has two modes (legacy proxy-based eval + isolated-vm). For v0.1 we ship a **third mode**: pure Rust template substitution with a curated function set, no JS engine at all.

```
"{{ $json.foo }}"           → JSON pointer lookup on item
"{{ $node.HTTP.json.id }}"  → cross-node reference, resolved against run_data
"{{ $now }}"                → built-in: chrono::Utc::now()
"{{ DateTime.now().plus({days: 7}) }}"  → not in v0.1 (luxon-equivalent is v0.2)
```

Parser: hand-rolled recursive descent. Handles `$json`, `$node.<name>.json.<path>`, `$items(...)`, `$workflow`, `$execution`, `$parameter`, `$now`, `$today`, `$env`. Function call syntax limited to the built-ins listed; no arbitrary JS. This loses some n8n flexibility but kills the whole sandboxing nightmare and ships in <300 lines.

For v0.2: optional `boa_engine` (pure Rust JS interpreter) feature flag for users who need full Luxon/Math/Date expression power. Fallback to literal substitution if boa is disabled.

### Workflow definition (TOML)

```toml
[workflow]
name = "webhook-to-slack"
version = 1

[[nodes]]
name = "Webhook"
type = "webhook"
[nodes.parameters]
http_method = "POST"
path = "incoming"

[[nodes]]
name = "Slack"
type = "slack"
credentials = "slack_main"
[nodes.parameters]
resource = "message"
operation = "post"
channel = "#alerts"
text = "{{ $node.Webhook.json.message }}"

[[edges]]
from = "Webhook"
to = "Slack"
```

TOML loader in `r8r-core::workflow::load_toml(path) -> Workflow`.

JSON workflow import from n8n exports is a v0.2 nice-to-have (n8n has tens of thousands of community workflows in JSON).

## Node interface (synthesized from Agent 2 exploration)

### The `Node` trait

```rust
#[async_trait]
pub trait Node: Send + Sync + 'static {
    fn metadata(&self) -> &NodeMetadata;
    
    async fn execute(&self, ctx: &mut ExecuteCtx) -> Result<NodeOutput, NodeError>;
}

pub struct NodeMetadata {
    pub name: &'static str,
    pub display_name: &'static str,
    pub version: &'static [u32],
    pub group: &'static [&'static str],
    pub description: &'static str,
    pub inputs: u8,
    pub outputs: u8,
    pub properties: &'static [PropertyDef],
    pub credentials: &'static [CredentialRef],
}
```

### Property types

Mirror n8n's 21 types with a Rust enum. `serde::Deserialize` to read from TOML.

```rust
pub enum PropertyType {
    String, Number, Boolean,
    Options(Vec<OptionDef>),
    MultiOptions(Vec<OptionDef>),
    Collection(Vec<PropertyDef>),
    FixedCollection(Vec<PropertyDef>),
    Json, DateTime, Color,
    ResourceLocator(Vec<ModeDef>),
    CredentialsSelect, ResourceMapper,
    Code(Language),
    Notice, Button, Callout,
}
```

`displayOptions` (conditional show/hide) → `DisplayOptions { show: Vec<DisplayCondition>, hide: Vec<DisplayCondition> }`. v0.1 evaluates these at parameter-read time inside `execute()`; UI-side hiding is moot until the editor exists.

### Registry pattern

Use the `inventory` crate for compile-time node registration:

```rust
inventory::collect!(NodeFactory);
inventory::submit! { NodeFactory::new("slack", || Box::new(SlackNode)) }
```

All 306 nodes self-register via a `flow_node!` macro in `r8r-codegen` output. Lookup is `O(1)` HashMap built once at startup.

### Versioning

n8n has many `Foo.node.ts` + `FooV1.node.ts` + `FooV2.node.ts` patterns where the bare file is a router. We collapse this: one Rust struct per node type, internal version dispatch via a `pub fn execute_vX(...)` family inside the impl. Codegen extracts version range from the router file.

### Credentials

```rust
pub struct CredentialDef {
    pub name: &'static str,
    pub display_name: &'static str,
    pub fields: &'static [PropertyDef],
    pub auth: AuthMethod, // BearerToken, BasicAuth, OAuth2, ApiKey, Custom
}

pub trait Credentials: Send + Sync {
    fn get(&self, name: &str) -> Option<&serde_json::Value>;
    fn inject(&self, req: reqwest::RequestBuilder, def: &CredentialDef) -> reqwest::RequestBuilder;
}
```

Storage in v0.1: TOML file at `~/.flow/credentials.toml` (encrypted optional via age). v0.2: keyring integration. v0.3: HashiCorp Vault for federal customers.

## Codegen pipeline (synthesized from Agent 2 exploration)

The 306-node parity claim depends on this pipeline. It runs once per n8n version bump.

### Step 1: TS AST extraction (`r8r-codegen` binary)

Use `swc_ecma_parser` (Rust crate) to parse every `.node.ts` file in `/home/mcochran/n8n/packages/nodes-base/nodes/` and `/home/mcochran/n8n/packages/@n8n/nodes-langchain/nodes/`.

For each file:
1. Find the exported class implementing `INodeType` or extending `VersionedNodeType`
2. Walk the class body, locate the `description` field initialization
3. Convert the literal object to a `serde_json::Value` (handle string-literal fields, number literals, array/object literals, recursive)
4. For `VersionedNodeType` routers: walk constructor `nodeVersions` map, follow imports to V1/V2/V3 files, extract version-range and merge
5. Walk `methods` field for loadOptions/listSearch/credentialTest names (we don't extract impls)
6. Emit one JSON file per node to `/home/mcochran/r8r/nodes-spec/<slug>.json` per the schema Agent 2 produced

### Step 2: Rust skeleton emission

Walk every `nodes-spec/*.json` and emit `crates/r8r-nodes/src/generated/<slug>.rs`:

```rust
// AUTO-GENERATED from /home/mcochran/n8n/packages/nodes-base/nodes/Slack/Slack.node.ts
// To implement operations: copy this file to ../impl/<slug>.rs and remove generated/<slug>.rs entry
use crate::*;

pub struct SlackNode;

#[async_trait]
impl Node for SlackNode {
    fn metadata(&self) -> &NodeMetadata { &SLACK_META }
    
    async fn execute(&self, ctx: &mut ExecuteCtx) -> Result<NodeOutput, NodeError> {
        Err(NodeError::NotYetPorted {
            node: "slack",
            upstream: "https://github.com/n8n-io/n8n/blob/master/packages/nodes-base/nodes/Slack/Slack.node.ts",
        })
    }
}

static SLACK_META: NodeMetadata = NodeMetadata {
    name: "slack",
    display_name: "Slack",
    version: &[1, 2],
    group: &["output"],
    description: "Consume Slack API",
    inputs: 1,
    outputs: 1,
    properties: &[ /* ...full property list from JSON... */ ],
    credentials: &[ CredentialRef { name: "slackApi", required: true } ],
};

inventory::submit! { NodeFactory::new("slack", || Box::new(SlackNode)) }
```

### Step 3: Hand-fill the T1 50

For each of the 50 priority nodes, write a real `impl Node` in `crates/r8r-nodes/src/impl/<slug>.rs` that supersedes the generated stub. Build script (or `mod.rs` selection) prefers `impl/` over `generated/` when both exist.

### The T1 50 list

**Control flow + transforms (26):** Code, Compression, Cron, Crypto, DateTime, EditImage, ExecuteCommand, ExecuteWorkflow, ExecutionData, Filter, Function, Html, If, Interval, ItemLists, Markdown, Merge, NoOp, RenameKeys, Schedule, Set, SplitInBatches, StopAndError, Switch, Wait, Xml

**Protocols (9):** EmailSend, Ftp, GraphQL, HttpRequest, Kafka, MQTT, RabbitMQ, Ssh, Webhook

**Databases (5):** Postgres, MySql, MongoDb, Redis, Elasticsearch

**AI (3):** OpenAi, Anthropic, plus the langchain `agents` umbrella

**Top SaaS (5):** Slack, Discord, Gmail, GitHub, Notion

**Cloud (2):** Aws/S3, Google/Sheets

Total: 50

## Tmux / fleet integration

### fleet.toml edit

Append to `/home/mcochran/fleet.toml`:

```toml
[[pane]]
name = "r8r"
dir = "~/r8r"
```

Pane will be window 7 in the c2 session (current windows 0-6 are C2, whobelooking, cochranblock, pixel-forge, oakilydokily, kova, ireducefraud).

### Live tmux window

```bash
tmux new-window -t c2 -n r8r -c ~/r8r
```

After window creation, the user (or a parallel Claude pane) launches `claude` inside it to start the r8r agent. The pane joins the c2 session and is dispatchable via `tmux-harness dispatch r8r '<task>'`.

### tmux-harness coordination

This bt node is the test specialist (per `~/CLAUDE.md`). The r8r pane on this node runs the test suite (TRIPLE SIMS, exopack visual regression, parity tests). The Mac Mini node owns the source-of-truth working copy (synced via `kova hive`). Codegen and node implementations are written from the Mac Mini pane; tests are dispatched to the bt r8r pane.

## Exopack / test wiring (synthesized from Agent 3 exploration)

### Workspace-level Cargo.toml

```toml
[workspace]
members = [
    "crates/r8r-core",
    "crates/r8r-engine",
    "crates/r8r-nodes",
    "crates/r8r-codegen",
    "crates/r8r-cli",
]
resolver = "2"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
inventory = "0.3"
petgraph = "0.6"
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4"
exopack = { path = "../exopack" }
swc_ecma_parser = "0.140"
swc_ecma_ast = "0.110"
swc_common = "0.32"
```

### CLI crate Cargo.toml (`crates/r8r-cli/Cargo.toml`)

```toml
[package]
name = "r8r-cli"
version = "0.1.0"
edition = "2024"
license = "Unlicense"

[features]
default = []
tests = ["dep:exopack", "dep:colored"]

[[bin]]
name = "r8r"
path = "src/main.rs"

[[bin]]
name = "r8r-test"
path = "src/bin/r8r-test.rs"
required-features = ["tests"]

[dependencies]
r8r-core = { path = "../r8r-core" }
r8r-engine = { path = "../r8r-engine" }
r8r-nodes = { path = "../r8r-nodes" }
clap.workspace = true
tokio.workspace = true
exopack = { path = "../../../exopack", optional = true, features = ["screenshot", "interface", "triple_sims", "devtools", "standards_check"] }
colored = { version = "2", optional = true }
```

### Test binary entry point

```rust
// crates/r8r-cli/src/bin/r8r-test.rs
#[tokio::main]
async fn main() {
    let ok = exopack::triple_sims::f60(run_once).await;
    std::process::exit(if ok { 0 } else { 1 });
}

async fn run_once() -> bool {
    smoke_workflow().await
        && expression_eval_test().await
        && codegen_extraction_test().await
        && parity_50_t1_nodes_test().await
}
```

### Cursor rules

Copy `/home/mcochran/cochranblock/.cursor/rules/rough-draft-exopack.mdc` verbatim to `/home/mcochran/r8r/.cursor/rules/rough-draft-exopack.mdc`.

## README.md (Tanner-addressed)

Tone is operator-class, gift-shaped, not over-explaining. Single page. Includes:

1. One-paragraph hook: "I rewrote n8n in Rust because Node has no business in an air-gap. 5MB binary, 306 nodes registered, 50 fully working, codegen pipeline for the rest. If you want to take the codegen and the long tail, the repo is yours at 49.5/49.5."
2. Quickstart (`cargo run --bin r8r -- run examples/webhook-to-slack.toml`)
3. Architecture diagram (5 crates, codegen flow)
4. The split: "Here's what's mine (engine, T1 nodes, federal go-to-market). Here's what I want you to own if you say yes (codegen, T2/T3 nodes, the canvas if we ever build one)."
5. The math (file sizes, node counts, T1 vs T2 vs T3)
6. License, headers, intent

## Milestone schedule (7 days to handable)

| Day | Deliverables | Verification |
|---|---|---|
| 1 | Repo created (private), workspace scaffolded, fleet.toml + tmux pane, README + PLAN copied in, headers applied, first compile succeeds | `cargo check` green, `git log` shows initial commit, `tmux list-windows -t c2` shows r8r |
| 2 | `r8r-core` types + TOML loader. `r8r-engine` executor skeleton. 3 nodes (HttpRequest, Set, If) hand-built in `r8r-nodes` | `cargo run --bin r8r -- run examples/trivial.toml` executes a 3-node workflow end-to-end |
| 3 | Expression evaluator (custom, no JS). Webhook node with axum server. Cron node with tokio-cron-scheduler | `cargo run --bin r8r -- run examples/webhook-cron-pipeline.toml` |
| 4 | `r8r-codegen` v1 - parses 306 `.node.ts` files, emits 306 JSON specs, emits 306 stub `.rs` files. All 306 nodes registerable | `cargo run -p r8r-codegen -- /home/mcochran/n8n` produces nodes-spec/ + generated/ ; `cargo build` succeeds |
| 5 | Hand-fill 20 more T1 nodes: Postgres, MySql, MongoDb, Redis, Slack, Discord, Gmail, GitHub, OpenAi, Anthropic, EmailSend, Ftp, Ssh, GraphQL, Code, Switch, Merge, Wait, Cron (real impl), DateTime | Each has a minimal example workflow + smoke test |
| 6 | Hand-fill remaining T1 (~25 more). exopack TRIPLE SIMS test runner. visual-regression baseline for any HTML output (the editor placeholder page) | `cargo run --features tests --bin r8r-test` passes 3 sims |
| 7 | Tanner-addressed README polish, demo workflows (vcenter, self-deploy, webhook-to-slack), record a 60-second demo video via exopack, push final, send Tanner the repo URL via whatever channel he reads | `gh repo view --web` confirms private + accessible to Tanner if added as collaborator |

## Files to be created

Critical files (paths absolute):

- `/home/mcochran/r8r/Cargo.toml` (workspace root)
- `/home/mcochran/r8r/UNLICENSE`
- `/home/mcochran/r8r/README.md` (Tanner-addressed)
- `/home/mcochran/r8r/PLAN.md` (this plan, copied)
- `/home/mcochran/r8r/.gitignore`
- `/home/mcochran/r8r/.cursor/rules/rough-draft-exopack.mdc` (copied from cochranblock)
- `/home/mcochran/r8r/crates/r8r-core/{Cargo.toml, src/lib.rs, src/types.rs, src/expr.rs, src/workflow.rs}`
- `/home/mcochran/r8r/crates/r8r-engine/{Cargo.toml, src/lib.rs, src/executor.rs, src/registry.rs, src/state.rs}`
- `/home/mcochran/r8r/crates/r8r-nodes/{Cargo.toml, src/lib.rs, src/impl/*.rs (T1), src/generated/*.rs (T2)}`
- `/home/mcochran/r8r/crates/r8r-codegen/{Cargo.toml, src/lib.rs, src/main.rs, src/extract.rs, src/emit.rs}`
- `/home/mcochran/r8r/crates/r8r-cli/{Cargo.toml, src/main.rs, src/bin/r8r-test.rs}`

Files to be modified:
- `/home/mcochran/fleet.toml` (append `r8r` pane)

Files NOT to be created (per the no-T3 directive):
- Operation implementations for the 250 T2 nodes (they ship as `NotYetPorted` stubs)

## Verification

End-to-end test commands after Day 7:

```bash
# Build both binaries
cd /home/mcochran/r8r
cargo build --release --bin r8r
cargo build --features tests --bin r8r-test

# Binary size check (target: <10MB)
ls -lh target/release/r8r

# Node count check (target: 306 registered)
./target/release/r8r list-nodes | wc -l

# T1 functionality check
./target/release/r8r run examples/webhook-to-slack.toml &
curl -X POST -d '{"message":"hello"}' http://localhost:8080/incoming
# expect: slack message posted, workflow exits cleanly

# Codegen reproducibility
cargo run -p r8r-codegen -- /home/mcochran/n8n
git diff --stat crates/r8r-nodes/src/generated/
# expect: zero diff if codegen is deterministic

# TRIPLE SIMS gate
cargo run --features tests --bin r8r-test
# expect: exit 0, 3 successful runs

# tmux pane presence
tmux list-windows -t c2 | grep r8r
# expect: r8r shown as one of the windows
```

If all green, the artifact is handable. The commit message for the final push: "v0.1: handable artifact for tanner". Repo URL goes to him through whatever channel he actually reads.
