# Nexus Network DSL — Design Document

This document describes the design for Nexus's network description features: node type declarations, node instantiation, groups, ports, connections, and library imports.

## Current Implementation State

| Layer       | Status  | Notes                                                                                                                                                                                                                                                                                                                                                                           |
| ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Scanner     | Done    | `node`, `group`, `port`, `use`, `type`, `in`, `out`, `inout`, `->`, `PortRef`, `self` tokenize; no further scanner changes needed through Step 9                                                                                                                                                                                                                                |
| Grammar     | Locked  | EBNF rules defined in `README_LLM.md`; D18 (group schemas) and D19 (`self.NAME` ownership) incorporated; `port_decl` now accepts arbitrary expressions (evaluated to `PortRef` at runtime)                                                                                                                                                                                      |
| AST         | Done    | All network AST nodes in: `Stmt::NodeTypeDecl`, `Stmt::Connect`, `Stmt::MemberDecl`, `Stmt::PortDecl`, `Stmt::UseDecl`, `PortSpec`, `PortDirection`, `Expr::MemberAccess`, `Expr::Node`, `Expr::Group`, `Expr::Self_`, `TypeId::PortRef`                                                                                                                                        |
| Parser      | Done    | All network parsing in: `parse_node_type_decl` + `parse_port_spec` + `parse_node_expr` + `parse_connect_stmt` + `parse_group_expr` + `parse_group_stmts` + `parse_assignment_or_member_decl` + `parse_port_decl` + `parse_use_decl`; `Expr::Self_` parsed; `PortRef` as type id; `PortDeclOutsideGroup` + `UseDeclNotTopLevel` errors; `try_parse_keyword_stmt` shared dispatch |
| Interpreter | Partial | All network semantics through Step 9 in; `use` imports with canonical-path dedup, flat-merge into global scope, transitive imports, `NodeId` uniqueness across imports; `-I`/`--search-path` CLI flag; full `Network` API (Step 10) pending                                                                                                                                     |

## Intended Usage Patterns

Derived from `examples/example1.nxs` through `example4.nxs`.

### Node type declarations and library imports

```nexus
// audio.nxs — component library
node type Source {
    out output;
}

node type Sink {
    in input;
}

node type Converter {
    in input;
    out output;
}

node type Mixer {
    in left, right;
    out mixed;
}
```

```nexus
// main.nxs
use audio;
```

### Flat network — nodes + connections inside a group

```nexus
use audio;

let system = group {
    self.source    = node Source;
    self.processor = node Converter;
    self.sink      = node Sink;

    self.source.output    -> self.processor.input;
    self.processor.output -> self.sink.input;
};
```

### Hierarchical groups — ports as the interface

```nexus
use audio;

let ingest = group {
    self.source    = node Source;
    self.converter = node Converter;

    self.source.output -> self.converter.input;

    port input  = self.source.input;
    port output = self.converter.output;
};

let egest = group {
    self.converter = node Converter;
    self.sink      = node Sink;

    self.converter.output -> self.sink.input;

    port input  = self.converter.input;
    port output = self.sink.output;
};

ingest.output -> egest.input;
```

### Nested groups — sub-group as a member

A sub-group is a member like any other. Its internal members are private to it; only its declared ports are addressable from the enclosing group.

```nexus
use audio;

let system = group {
    self.source = node Source;

    // Inline sub-group, bound as a member of `system`.
    self.pipeline = group {
        self.stage1 = node Converter;
        self.stage2 = node Converter;
        self.stage1.output -> self.stage2.input;

        port input  = self.stage1.input;
        port output = self.stage2.output;
    };

    self.sink = node Sink;

    self.source.output   -> self.pipeline.input;
    self.pipeline.output -> self.sink.input;
};
```

From the outer body, `self.pipeline.stage1` is an error — `stage1` is not in `pipeline`'s port schema; only `input` and `output` are. Directions propagate up the chain automatically: the `in` on `Converter.input` becomes the direction of `pipeline`'s `input` port (inferred at the sub-group's closing `}`), which in turn is what the outer connection checks against.

### Factory functions returning groups

```nexus
use audio;

fn create_system() -> Group {
    group {
        self.source = node Reader;
        self.sink   = node Writer;

        self.source.output -> self.sink.input;

        port input  = self.source.input;
        port output = self.sink.output;
    }
}

let mut app: [Group; 4];
for i in 0..4 {
    app[i] = create_system();
}
```

## Grammar Additions

All grammar for network features lives in `README_LLM.md`. Summary of what's new for Milestone 5:

- `node_type_decl → 'node' 'type' ID '{' port_spec* '}'` — declaration of an external component type.
- `port_spec → ( 'in' | 'out' | 'inout' ) ID ( ',' ID )* ';'` — port with direction and name(s). No data types, no arity.
- `node_expr → 'node' ID` (was `'node' STRING`) — instantiation takes a bare identifier referring to a declared node type, not a string literal.
- `use_decl → 'use' ID ';'` — library import.
- `member_decl → 'self' '.' ID '=' expr ';'` — binds a node or sub-group to the enclosing group. Valid only inside `group { ... }`.
- `port_decl → 'port' ID '=' expr ';'` — delegates a named group port to any expression that evaluates to a port reference at runtime. Typically `self.member.port` or an indexed expression like `arr[i].port`.

> **NOTE 1**: `in` is reused from the `for..in` keyword. Context disambiguates: `in` at the start of a statement inside a `node type` block is a port direction; `in` after an identifier in a `for` loop is the iterator keyword.

> **NOTE 2**: `type`, `out`, and `inout` are new reserved words (globally reserved, not contextual). `self` is a contextual keyword — it is only reserved at the start of a statement inside a `group { ... }` body; elsewhere it is a valid identifier.

> **NOTE 3**: `node_type_decl` and `use_decl` are valid only at file top level, not inside blocks, groups, or function bodies. Enforced at parse time.

> **NOTE 4**: `member_decl` and `port_decl` are valid only at the top level of a `group { ... }` body (not inside nested blocks, `if`/`for`/`while`, or function bodies declared within the group). Enforced at parse time.

## Semantic Model

### `node type Name { ... }` — node type declaration

Defines the schema for an external component type: a name and a set of directed ports. Each port has a direction (`in`, `out`, or `inout`) and a name.

- Registers a schema in the node type registry: a type name mapped to a set of `(direction, port_name)` pairs.
- Duplicate `node type` declarations for the same name are an error.
- Port names must be unique within a declaration (no duplicate names regardless of direction).
- `node type` is a declaration (like `fn` or `const`), not an expression.

Ports can be declared individually or grouped by direction:

```nexus
node type Mixer {
    in left, right;
    out mixed;
}
```

### `use name;` — library import

Loads an external `.nxs` file and flat-merges its top-level declarations into the importing file's top-level scope. Library files are plain Nexus files that may contain `node type` declarations and other Nexus code (constants, helper functions, etc.).

- `use name;` resolves to `name.nxs` **relative to the directory of the importing file**. Each `use` is resolved against its own source file, not the process CWD or the root program file.
- Additional search directories may be configured via the `-I`/`--search-path` CLI flag (repeatable); the per-file default search path is primary.
- Each file is loaded at most once per program, tracked by **canonical absolute path** (symlinks resolved with `fs::canonicalize`). Diamond and circular dependencies resolve naturally — second and later `use` entries for an already-loaded file are skipped.
- **All top-level declarations** from the imported file are imported: `node type`, `const`, `fn`, etc. There is no visibility marker (`pub`/private). Libraries expose their full top-level surface.
- Imports are **transitive**: if `foo.nxs` does `use bar;`, any file that does `use foo;` also sees `bar`'s declarations. Flat-merge propagates naturally.
- **Name collisions** between decls imported via different `use` statements are an error at load time, pointing at both source locations. Last-wins is rejected.

### `node TypeName` — node instantiation

Creates an instance of a declared external component type. The identifier must refer to a `node type` declaration in scope (either declared in this file or imported via `use`). If no matching declaration exists, it's a hard error at interpretation time — the same error you get for any undefined identifier.

Each `node` expression produces a distinct instance with a unique `NodeId`. The node's source-level name is derived from a **direct** `let`/`const` binding — i.e., when the `node TypeName` expression is the initializer of the binding, verbatim (e.g., `let source = node Source;` produces a node named `"source"` of type `"Source"`). Two `node Source` expressions create two separate components with distinct `NodeId`s.

Anonymous node expressions get a unique internal name of the form `node_<N>`, where `<N>` is a monotonic counter (shared with `NodeId` allocation). Anonymous naming applies whenever the `node` expression is not the direct RHS of a `let`/`const`: bare statements (`node Source;`), array elements, function returns, closure bodies, block result expressions, etc. Source code cannot refer to these instances by name, but they still participate in the output model via their `NodeId`. The type name is not embedded in the anonymous form — `node type` declarations share a single flat namespace, so uniqueness is carried by the counter alone; keeping the anonymous prefix constant also makes every anonymous node visibly a "node" at a glance regardless of its underlying schema.

The expression-type of `node TypeName` is the built-in `Node`, regardless of the schema name. The schema name is used only for port validation at runtime; it is not itself a type in the type system. A variable holding a node is declared `let x: Node = node Foo;`, not `let x: Foo = ...`.

### Member access (`.field`) — port references

Member access on a `Value::Node` or `Value::Group` produces a `Value::PortRef` — a value that identifies a specific port on a specific node or group instance. The port name is validated against the owner's schema at access time; accessing an undeclared port is a hard `UndefinedPort` error.

`Value::PortRef` carries: the owning node's `NodeId` (stable identity), the node's source-level name (for diagnostics and `Display`), a cloned `Arc<NodeTypeSchema>` (so connection validation in Step 5 looks up port direction directly), and the port name. Port direction itself is not frozen in the ref — it is re-derived from the schema at connection time, matching D1.

PortRef values are first-class: they can be bound (`let p: PortRef = src.output;`), passed into closures, and stored in locals. They are not comparable with `==`/`!=` — two `PortRef`s to the same port are still an `InvalidComparison` error, because equality has no meaningful semantics for a port identity in v1.

Member access on non-network types (Number, String, etc.) remains an `UndefinedField` error. Method calls (`.method()`) are handled separately via the existing `Expr::MethodCall` mechanism.

### `self.name = expr;` — member binding

Binds a node or sub-group to the enclosing group, making it a permanent member of that group. Valid only at the top level of a `group { ... }` body. The RHS must evaluate to a `Value::Node` or `Value::Group`; any other value is a hard error.

- Each member name is unique within its group — a second `self.source = ...;` in the same body is a duplicate-member error.
- Member bindings are immutable: a member cannot be reassigned after declaration. The member set is frozen at the group's closing `}`.
- Members are addressed as `self.NAME` from within the enclosing body (both in connection statements and port declarations). Bare `NAME` does not resolve to a member — it refers to ordinary local bindings (`let`, `const`, function parameters), which coexist with the member namespace.
- Anonymous nodes and groups created without a `self.NAME = ...` binding still participate in the output model via their `NodeId`/`GroupId`, exactly as top-level anonymous nodes do (see D9). They are, however, not reachable by name from the group body and cannot be used as connection or port-declaration endpoints once their statement has executed.

Member bindings give a group explicit ownership over its interior. They also eliminate a shadowing hazard: because port declarations and connections must use the explicit `self.NAME.port` form, they cannot be silently retargeted by a later `let NAME = ...` in the same body.

### `port name = expr;` — group port declaration

Binds a name to a port reference evaluated at runtime. The expression must evaluate to a `Value::PortRef` — typically `self.member.port` or an indexed form like `arr[i].port`. Ports are the group's public interface — the only way to wire into or out of a group from the outside.

Ports are immutable, non-reassignable declarations. `port` is valid only inside a `group { ... }` block. The declaration is validated:

- The expression must evaluate to a `PortRef`. Any other result (`Number`, `Group`, etc.) is a hard `PortDeclNotAPortRef` error.
- The target port must exist on the owner's schema — this is already guaranteed when the `PortRef` is produced (port existence is validated at access time).
- Port names must be unique within the group — a second `port input = ...;` in the same body is a duplicate-port error.

The group port's direction is inferred from the target `PortRef`'s schema: `in → in`, `out → out`, `inout → inout`. Delegation preserves directionality — the group port points in the same direction as the member port it exposes.

Group ports and node ports are unified in the output model: both appear as `Port` entries on the owning `Node`. A group's declared ports become the ports of the output `Node` that represents the group.

### `expr -> expr;` — connection

Records a directed edge from a source port to a destination port. Both sides must evaluate to `Value::PortRef`. This is a statement, not an expression — it does not produce a value.

**Unified schema validation.** Connection checking uses a single code path that accepts either a node or a group endpoint — both carry a schema with the same shape (a list of directed ports). The validator:

1. Resolves each endpoint to its owner's schema.
2. Checks the named port exists on that schema.
3. Checks directional compatibility. Legal: `out → in`, `out → inout`, `inout → in`, `inout → inout`. Everything else — including `in → *` and `* → out` — is an error.

Because the validator is schema-agnostic (it doesn't care whether the box is a node or a group), cross-kind connections — `node.port -> group.port`, `group.port -> group.port`, and so on — are validated identically. Every port access in the program is checked against a schema; there is no ad-hoc corner.

All connections are collected into a single flat list owned by the `Network` output — not by individual nodes or groups. This avoids duplication and provides a single source of truth for all wiring.

Chaining (`a -> b -> c;`) is not supported. The grammar defines `connect` as binary.

### `group { ... }` — group expression

A group is a composite: it contains owned member nodes and sub-groups, connections between them, and a set of named ports that form its external interface. A group block evaluates to a `Value::Group`.

Inside a group block:

- `let` / `const` / `fn` declarations work normally — they create ordinary local bindings (helpers, constants, closures, temporary values). Ordinary `let` bindings do **not** become members of the group, and their names cannot appear in connection statements or port declarations.
- `self.NAME = expr;` binds a node or sub-group as a member of the enclosing group (see the member-binding section). Members are addressed as `self.NAME` from the body.
- `port name = expr;` declares a named port on the group's boundary; `expr` must evaluate to a port reference (typically `self.member.port` or an indexed form like `arr[i].port`).
- `expr -> expr;` records a connection edge between two port paths. Endpoints use the explicit `self.member.port` form when the owner is a group member.

**Schema construction.** A group's member set and port interface are computed during evaluation, not declared in advance. As the body executes, each `self.NAME = ...;` contributes one member binding, and each `port NAME = self.member.port;` contributes one entry to an accumulating schema (name, inferred direction, internal target). Connection statements inside the body are validated against the current member map. At the closing `}`, the member set and port schema are frozen and stored on the resulting `Value::Group`.

From that point on, a group value is indistinguishable from a node value for validation purposes — both are "boxes with a directed port interface". All port accesses and connections against the group (including from outside it) use the frozen schema identically to how they use a node's schema. This is how strictness becomes watertight across the whole program: no endpoint escapes schema checking.

Each `group { ... }` evaluation produces a fresh internal `GroupId` (monotonic counter), analogous to the internal naming of anonymous nodes (D9). Source code cannot address a group by `GroupId`; human-readable identity still comes from `let` bindings.

In the output model, a group becomes a `Node` with `type_name: "Group"` and non-empty `children`. There is no separate group type in the output — nodes and groups are unified. The group's internal nodes become its `children`, its declared ports become its `ports`, and its connections are added to the global connection list.

The group stores its network description (nodes, ports, connections, schema), not a closure over its evaluation scope.

### Strictness model

Nexus is strict about network validation — there is no permissive mode, and no plan for one. Strictness applies **uniformly to nodes and groups**:

- All node types must be declared before use (directly or via `use`). Unknown type names are a hard error.
- All port accesses — whether on a node or on a group — are validated against the owner's schema. Unknown port names are a hard error.
- All connection directions are checked. Anything other than `out → in`, `out → inout`, `inout → in`, or `inout → inout` is an error.
- Group member bindings (`self.NAME = expr;`) are validated: RHS must be a node or sub-group value, member names must be unique within the group, and members are immutable.
- Group port declarations require the `port NAME = <expr>;` form where `<expr>` evaluates to a `PortRef`. The target port must exist on the owner's schema (validated at `PortRef` creation), and the group port's direction is inferred from that schema.
- Connections inside a group body use the explicit `self.member.port` form on each endpoint whose owner is a group member. Bare-identifier port paths (e.g., `foo.output`) do not resolve to group members — they refer to ordinary locals, which are not part of the group's interface and cannot be connected as ports.
- A group's schema is constructed during body evaluation, frozen at the closing `}`, and used identically to a node's schema for all subsequent validation. Group-to-group, group-to-node, and node-to-group connections share one validation path with node-to-node.

Everything above is caught at interpretation time. The reason: Nexus drives an external system. An error caught at interpretation time — in a file the user can debug with source locations — is dramatically cheaper than the same error surfacing as mis-wiring in the downstream network. A "lax" mode was considered and rejected: it would allow silent typos (`source.outpt` instead of `source.output`) to pass validation and produce an invalid network. "Correctness at construction" is the design goal.

Nexus also has no namespacing distinction between types and values: declaring `node type Foo { ... }` reserves `Foo` in the single shared namespace, same as `let Foo` or `fn Foo` would.

## Design Decisions

### D1: Port path representation

`Value::PortRef` carries the owning node's `NodeId` (stable identity), the source-level name (for diagnostics), a cloned `Arc<NodeTypeSchema>` (so schema lookups during connection validation don't need a side registry), and the port name. Member access (`.field`) on a `Value::Node` or `Value::Group` produces a `Value::PortRef`. Port direction is looked up from the schema at connection time, not stored in the ref — keeping "direction is a property of the port on the schema, not of the ref" as a one-way invariant. The schema `Arc` is a cheap clone and avoids maintaining a `NodeId → schema` side table on the interpreter.

### D2: Unified output model

Nodes and groups are unified in the output: both are represented as `Node` (see `README_INTEGRATION.md`). A group is a `Node` with `type_name: "Group"` and non-empty `children`. This simplifies the output API — consumers work with one type, not two.

The interpreter may still use separate `Value::Node` and `Value::Group` internally. The unification happens at the output boundary when building the `Network`.

### D3: Top-level connections and the implicit root group

The entire program is wrapped in an implicit root group. Top-level connections and nodes belong to it. The program's output _is_ a network description, so an implicit top-level group is the natural model. The root is represented as a `Node` with `name: "root"` and `type_name: "Group"`.

### D4: Global connection ownership

All connections are owned by the `Network`, not by individual nodes or groups. A connection is between two nodes — neither should "own" it. The flat global list avoids duplication and provides a single source of truth.

### D5: Group cloning

Clone produces a deep copy with fresh `NodeId`s. This matches Rust value semantics and makes factory functions work correctly — each call returns an independent group.

### D6: Member access scope

`.field` only works on `Value::Node` and `Value::Group`, producing port references. On other types, it is an error. General struct/record support is a separate, larger feature. Method calls (`.method()`) are already handled separately via `Expr::MethodCall`.

### D7: Connection validation

Schema validation. `->` requires both sides to be `PortRef`, validates that ports exist on the declared node type, and checks that the left side is `out` or `inout` and the right side is `in` or `inout`. Direction mismatches are an error.

The direction validator is intentionally schema-agnostic: it takes only the two resolved `PortDirection` values and returns a `bool`. Port existence is already checked when the `PortRef` is produced (in `eval_member_access`), so the connect step is reduced to direction lookup + compatibility test. Because both nodes and groups expose a `NodeTypeSchema`-shaped `Vec<(String, PortDirection)>`, Step 8 (PortRef on `Value::Group`) reuses this validator unchanged — that is the whole point of the shape-matching discipline. Duplicate connections (same `(from_node_id, from_port, to_node_id, to_port)` quadruple) are rejected with a distinct `DuplicateConnection` error; a duplicate carries no additional semantics over a single edge and is almost certainly a typo. Self-loops (`a.output -> a.input`) are allowed — the direction rules alone govern legality.

### D8: Connection chaining

Not supported. `connect` is binary. Users write separate statements:

```nexus
a.output -> b.input;
b.output -> c.input;
```

### D9: Node naming

Each node's `name` is derived from the `let`/`const` binding in source code, but only when the `node TypeName` expression is the **direct** initializer of the binding (not routed through a block, closure call, function return, or other indirection). Names are local (not nested paths). The `Network` provides helper methods to build fully qualified paths (e.g., `"ingest.converter"`) by walking the tree. `NodeId` (a `u64`) provides a machine-friendly stable identifier for O(1) connection lookups without tree traversal.

Anonymous `node TypeName` expressions — those not bound directly to a `let`/`const` — are given an internal unique name of the form `node_<N>`, where `<N>` is a monotonic counter (shared with `NodeId` allocation). The source cannot refer to them by name (analogous to C++ lambda types being unnameable), but they still get a stable `NodeId` and appear in the output model. The type name is deliberately not embedded in the anonymous prefix: node type names share a single flat namespace, so uniqueness is carried by the counter alone, and a constant `node_` prefix makes every anonymous instance visibly a "node" at a glance regardless of its schema.

### D10: Strict-only mode, no opt-out

Nexus enforces node type declarations, port validation, and direction checks unconditionally. A "permissive" or "non-strict" mode was considered and rejected because the JS-style ad-hoc field model it would imply (silent typos creating new port references) defeats the whole point of a DSL that drives an external system. See "Strictness model" above.

### D11: Bare identifier for node instantiation

`node TypeName` (not `node "TypeName"`). Symmetric with how built-in types (`Number`, `Group`) are written as bare identifiers in type positions. Typos are caught by the normal "undefined identifier" machinery, no separate "unknown node type name" diagnostic needed. Strings are opaque to scoping and tooling; identifiers are not.

### D12: Single flat namespace

Types and values share one namespace. `node type Foo` reserves `Foo`; a later `let Foo = ...` in the same scope is an error. Consistent with how Nexus already treats built-in type names (`Number`, `Group`, etc.) as reserved. Rust-style separate type/value namespaces were considered; rejected because Nexus has no traits/generics pressure that would justify the complexity.

### D13: Node expression-type is `Node`; schema name is not a type

The expression-type of `node Foo` is `Node`, the built-in base type. The schema name `Foo` is a runtime validation key, not a type-system type — `let x: Foo = ...` is invalid; write `let x: Node = node Foo;`. This keeps the type system flat and avoids introducing a subtype layer for v1. Port-reference validation happens at runtime based on the node's runtime schema name. If future needs justify distinguishing `Foo`-nodes from `Bar`-nodes at the type level (for function-over-nodes signatures), that is an additive extension.

### D14: `use` is transitive via flat-merge; no visibility markers

`use libname;` flat-merges all top-level declarations from the imported file into the importing file's top-level scope. Transitivity falls out naturally: if `foo.nxs` imports `bar.nxs`, `bar`'s decls end up in `foo`'s top-level scope, and anything that imports `foo` also sees them. No `pub`/private distinction in v1 — libraries expose their full top-level surface. Revisit only if library size creates real pressure for encapsulation.

### D15: Collisions via `use` are errors, not last-wins

If two `use` statements bring in decls with the same name, the second is an error at load time, with the diagnostic pointing at both source locations. Last-wins was rejected as fragile. Module-prefix syntax (e.g., `audio::Source`) is not in v1; it is an additive extension if the need arises.

### D16: Path resolution is relative to the importing file

Each `use libname;` resolves `libname.nxs` against the directory of the file that contains the `use` statement, not the process CWD or the root source file. Standard module-system behaviour; keeps library groupings portable regardless of where the interpreter is invoked from. CLI flags for additional search directories are an additive extension.

### D17: Port specs are direction-only; no port data types, no arity, no properties

`port_spec → ( 'in' | 'out' | 'inout' ) ID ( ',' ID )* ';'` — direction and name(s) only. Data-typed ports were considered and deferred: "data-laundering" infrastructure nodes (universal queues, muxers, serializers) would require classifying nodes by their type-propagation behaviour, cascading into a second design layer. Connection validation stays at the direction level. Port arity (`in inputs[2]`) and node properties (`node Foo { prop: value }`) are similarly deferred — both require type systems (for index ranges, for property values) that are out of scope for v1.

### D18: Groups carry a schema, checked like nodes

A `group { ... }` expression computes its port schema at evaluation time by collecting every `port NAME = <expr>;` declaration in the body, evaluating each expression to a `PortRef` and inferring port direction from it, and freezing the result at the closing `}`. The `Value::Group` carries this schema — names, directions — for the lifetime of the value.

Connection validation is a single schema-agnostic operation. Both node and group endpoints expose a schema of the same shape (a list of `(name, direction)` pairs); the validator checks port existence and direction compatibility without caring which kind of box owns the schema. This makes strict checking watertight across the whole program — node-to-node, node-to-group, group-to-group, and group-to-node connections are all checked identically. Together with D10 (strict-only mode) and D13 (schemas are runtime validation keys, not type-system types), D18 closes the gap that existed in v1's initial design: groups were schema-less, so cross-group connections couldn't be validated. Now they can.

Design constraints that follow:

- `port NAME = <expr>;` — the expression must evaluate to a `PortRef` at runtime. Direction is inferred by reading the port's direction from the `PortRef`'s schema (`in → in`, `out → out`, `inout → inout`). Typical form is `self.member.port`, but indexed forms like `arr[i].port` are also legal.
- Direction inference is mechanical: the group port's direction matches the target port's direction.
- Each `group { ... }` evaluation produces a fresh internal `GroupId` for identity and diagnostics, analogous to anonymous-node internal naming (D9). Source code cannot address a group by `GroupId`; names come from `let` bindings.
- Function signatures still cannot constrain a group's shape — every group types as `Group`, matching how every node types as `Node` (D13). Schema-level distinctions are runtime-only. If future needs justify a "group of shape X" type, that is an additive extension.
- Structural equivalence is not exploited: two groups with identical port interfaces are distinct schemas. A schema-interning optimization would be additive.
- Alternative designs considered: (a) a `group type` declaration for shared group schemas — rejected as premature; group bodies already express the interface, and reuse is covered by factory functions. (b) lazy schema construction deferred until first external access — rejected as more complex than eager freezing at `}` without benefit.

### D19: Node ownership via `self.NAME`

A group's member nodes and sub-groups are declared explicitly with `self.NAME = expr;`. Ordinary `let` / `const` / `fn` declarations inside a group body produce ordinary local bindings — helpers, constants, closures — and are **not** members of the group. Only values bound via `self.NAME = ...` become part of the group's owned structure.

This resolves the ambiguity that existed in earlier drafts: the relationship between local bindings (which are lexically scoped) and member nodes (which have group-bound lifetime and identity). Explicit `self.*` makes the membership decision a syntactic choice rather than an interpretation of whether a value "happens to" end up referenced by a port declaration or connection. Nodes and sub-groups created without a member binding still receive a `NodeId`/`GroupId` and appear in the output model as anonymous children of the enclosing group; they cannot, however, be referenced by name afterwards.

`self` is a contextual keyword: it is a reserved start-of-statement token only inside a `group { ... }` body. Outside group bodies it remains a valid identifier, preserving backward compatibility.

Design constraints that follow:

- Port declarations use the form `port NAME = <expr>;` where `<expr>` must evaluate to a `PortRef`. The typical form `self.member.port` is unambiguous and robust under refactoring; indexed forms like `arr[i].port` are also supported when group members are held in arrays.
- Connection endpoints that reference a group member must use the explicit `self.member.port` form too. Bare identifiers in connection expressions refer to ordinary locals — useful when a local holds a `PortRef` produced by some earlier expression — but never to group members. The explicit-`self.*` rule governs the _path syntax_ used to name a member; it does not restrict what kinds of values can appear as connection endpoints. A local variable holding a `PortRef` value produced earlier (e.g., `let p = self.source.output;`) is a legal endpoint on its own, because the restriction is about how member paths are written, not about whether `PortRef` values are allowed.
- Member bindings are immutable after declaration. Reassigning `self.NAME` (either with `=` or with a compound operator) is a hard error with a distinct `MemberReassignment` error kind — not merged with generic "variable redeclaration". Shadowing is not supported for members. This keeps ownership structurally static — a group's children set is known exactly once its `}` has been reached.
- Member bindings must not collide with built-in member names that may be added later (e.g., hypothetical `self.ports`, `self.children`). V1 reserves no such names, but future additions are an additive extension.
- Parent-claim rule for node and group identity: the first `self.NAME = expr;` binding that references a particular `NodeId` or `GroupId` claims parenthood in the output tree. A subsequent attempt to make the same `NodeId`/`GroupId` a member of a different group is a hard error (distinct `DuplicateMembership` error kind). This keeps the output tree well-formed (each node has exactly one parent) while still permitting the common pattern of building a node/group in a local and moving it into a `self.*` binding. Same-group re-binding under a different name is rejected under the duplicate-membership rule as well — an identity has exactly one member name.
- Bare `self` (i.e., `self` without a trailing `.IDENT`) is not a valid expression or statement. `self` is only syntactically legal as the prefix of `self.IDENT = ...` (member binding) or `self.IDENT ...` (member access). This keeps `self` strictly a path-prefix token; if a future need justifies reifying the enclosing group as a value, it is an additive extension.
- Empty groups are legal. `let g = group { };` evaluates to a `Group` value with an empty member set, an empty port schema, and an empty connection list — analogous to an empty function body.
- Members and ports occupy separate namespaces inside the group value. From outside the group, `g.foo` resolves by looking at the group's port schema only; the member map is never exposed by name through `.field` access. A member `self.foo = node X;` and a port `port foo = self.bar.baz;` in the same group coexist without conflict — but tooling should flag the collision as a style warning to keep diagnostics readable. (Internally, the distinction is clear: `self.foo` inside the body refers to the member; `g.foo` from outside refers to the port.)
- Alternative designs considered: (a) implicit membership — every `let name = node Foo;` inside a group body automatically becomes a member. Rejected because it conflates two different notions (local binding vs. owned child) and introduces silent shadowing hazards. (b) `member NAME = expr;` instead of `self.NAME = expr;` — rejected because `self.*` composes naturally with `self.member.port` in port declarations and connections, while a dedicated `member` keyword would not. (c) `this.NAME` instead of `self.NAME` — aesthetic preference; `self` matches Rust and is already a recognizable "the enclosing composite" indicator. (d) Allow non-node/non-group values (numbers, closures) on `self.*` — rejected: `self.*` is the group's owned network structure; anything bound there ends up in the output tree, and numbers/closures have no place there. Helpers belong on regular `let`/`const`/`fn`.

## Recommended Implementation Order

| Step | Status | Feature                                                                                                                                                                                                                                                                                                                            | Depends on | Complexity |
| ---- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------- |
| 1    | ✓ done | Member access (`.field`) — `Expr::MemberAccess` parsed in `parse_postfix()`; interpreter errors `UndefinedField` pending `Value::Node`/`Value::Group`                                                                                                                                                                              | —          | Small      |
| 2    | ✓ done | `node type` declarations + type registry — `Value::NodeType(Arc<NodeTypeSchema>)` in env, flat namespace, top-level-only parse-time check                                                                                                                                                                                          | —          | Medium     |
| 3    | ✓ done | `Value::Node`, `Expr::Node` — bare-identifier instantiation, monotonic `NodeId`, source-level name derived from enclosing `let`/`const` binding (or anonymous `node_<N>` otherwise)                                                                                                                                                | Step 2     | Small      |
| 4    | ✓ done | `Value::PortRef` on `Value::Node` — interpreter-only; `eval_member_access()` dispatches on `Value::Node` and produces a `PortRef` (carrying `NodeId`, node name, schema `Arc`, port name) after schema validation; `UndefinedPort` error kind for unknown port names; `TypeId::PortRef` added for type-annotation symmetry         | Steps 1, 3 | Small      |
| 5    | ✓ done | `Stmt::Connect` (`->`) with a schema-agnostic direction validator; connections stored as a flat `Vec<InternalConnection>` on the interpreter (root-group scaffold folds it in in Step 6); duplicate rejection; self-loops allowed                                                                                                  | Steps 3, 4 | Small      |
| 6    | ✓ done | `Value::Group` body + `Stmt::MemberDecl` (`self.NAME = expr;`); contextual `self` keyword; member map; `DuplicateMembership` and `MemberReassignment` error kinds; parent-claim tracking across `NodeId`/`GroupId`. Groups at this stage are "sealed containers" — body, members, internal connections, no external port interface | Steps 3-5  | Medium     |
| 7    | ✓ done | `Stmt::PortDecl` (arbitrary expression target evaluated to `PortRef` at runtime); incremental schema construction during body eval; direction inference (`in→in`, `out→out`, `inout→inout`) from schema of resolved `PortRef`; schema freeze at closing `}`                                                                        | Step 6     | Medium     |
| 8    | ✓ done | Extend `PortRef` production to `Value::Group` (look up against the frozen group schema); unified node+group connection validation uses the Step-5 validator unchanged                                                                                                                                                              | Steps 5, 7 | Small      |
| 9    | ✓ done | `use` declarations (file loading, canonical-path dedup, flat-merge into top-level scope, transitive imports, `NodeId` uniqueness, `-I`/`--search-path` CLI flag, scan/parse errors from imported files preserved as `#[related]` diagnostics)                                                                                      | Step 2     | Medium     |
| 10   |        | `Network` output bridge (interpreter → unified `Node`/`Network` API; see `README_INTEGRATION.md`)                                                                                                                                                                                                                                  | Step 8     | Medium     |

Notes on the order:

- Steps 3 and 4 are tightly coupled — 4 is purely extending the dispatch Step 1 already scaffolded, and together they make node ports usable.
- Step 5's design constraint (schema-agnostic validator) is load-bearing for Step 8: getting it right up front means group-endpoint validation in Step 8 is a one-line dispatch addition instead of a refactor.
- Step 6 (members) precedes Step 7 (ports) because port declarations typically resolve members via `self.*` expressions. Each sub-step is independently testable: 6 gives you sealed groups you can build and wire internally; 7 surfaces ports; 8 completes cross-group connectivity.
- Step 9 (`use`) was originally planned right after Step 2 but is pushed to near the end. It's pure module-system plumbing and doesn't inform the group design; postponing keeps the critical path focused on network semantics.
- Group factory patterns (arrays of groups, closures returning groups) are not a separate step — they exercise feature composition and belong in Step 8's test suite.
- Step 10 bridges to the unified output model (`Network`, `Node`) described in `README_INTEGRATION.md`.

## Grammar Notes

The grammar as defined in `README_LLM.md` is sound for the intended usage. Notable points:

- `node_type_decl` is a new declaration added alongside `fn_decl`, `const_decl`, etc.
- `port_decl` and `member_decl` are listed under `group_decl`, not `decl` — they are only valid inside `group { ... }` bodies (NOTE 5)
- `connect` is correctly a `stmt`, not an `expr` — avoids `->` conflicts with return-type arrow
- `node_expr` and `group_expr` are correctly `primary` expressions — they can appear anywhere an expression is expected (assignments, function returns, array elements)
- `postfix` already includes `'.' ID` in the grammar — the parser just doesn't implement it yet
- No chained connections — correct for simplicity

## Spec Completeness

`SPEC_HOLES.md` (repo root) is a living audit document that records every corner of the language that is undefined, ambiguous, or specified only by the current implementation. Each entry has a concrete `.nxs` repro and a proposed resolution; entries are tagged **FIXED**, **OBSERVED**, or left open.

A parallel set of **cross-feature tests** (prefix `cross_` in `tests/interpreter/{pass,fail}/`) pins behaviour at feature-combination boundaries that single-feature tests miss. They serve as both regression guards and executable documentation of observed semantics.

### Behavioural decisions made during the audit

| Area                     | Decision                                                                                                                                                          |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unary `+`                | Restricted to `Number`; any other type is `InvalidUnaryPlus` error                                                                                                |
| Uninitialized read       | Reading a variable before assignment is `UninitializedRead` error; `for..in` over an uninitialized array element raises the same error                            |
| Uninitialized array copy | Assigning a fully-uninitialized array (e.g. `[Group; N]` never filled) to a new variable without a type annotation is a `TypeMismatch` error — add `: [Group]`    |
| Closure capture          | Snapshot semantics: mutation inside a closure does not persist across calls                                                                                       |
| Recursive closures       | Supported: free identifiers resolved at call time, not capture time                                                                                               |
| Sized array on reassign  | Size annotation is a one-shot check at declaration; reassignment is unchecked                                                                                     |
| `if` without `else`      | Produces `Unit` when condition is false; binding that `Unit` is `ExpectedValue`                                                                                   |
| Network summary order    | Deterministic: nodes and groups sorted by path, connections by `(from, to)`                                                                                       |
| `_` discard              | `let _ = expr;` evaluates RHS for side effects (nodes/groups still register) but stores nothing; `_ = ...` is a parse error; reading `_` is `UndefinedIdentifier` |
| `assert` message         | Message expression is evaluated **lazily** (only on failure); non-`String` message is `TypeMismatch`; no message is evaluated at all when condition passes        |
