Status: Architecture review for the Nulang project, synthesized from a multi-layer audit of the current codebase and design documents.
Scope: Language core, compiler frontend, runtime, distributed systems, AI integration, developer experience, and long-term governance.
Date: 2026-07-06
Nulang is an ambitious alpha-stage language that fuses Erlang-style actors, Pony-inspired reference capabilities, Koka-style algebraic effects, a register VM, and a Cranelift JIT. The current codebase (~33 kLOC, 590+ tests) demonstrates that the straight-line pipeline works for small programs: lex → parse → Hindley-Milner typecheck → effect check → capability check → AST-to-bytecode compile → VM/JIT execute (src/main.rs:172-251). That is a real achievement.
However, breadth has been prioritized over depth and architectural coherence. The system is a stack of individually plausible subsystems glued directly to the AST. There is no intermediate representation, no real optimizer, no parser error recovery, no module system, and no separation between "what the user wrote" and "what the runtime executes". The type system is a partial HM implementation bolted to an ad-hoc effect-row checker and an approximate capability analyzer. Many features advertised in the README (AI agent DSL, deep Python integration, distributed actor migration) are removed, stubbed, or exist only as design documents.
Core thesis for 50-year relevance: Languages that survive are not the ones with the most features; they are the ones with the cleanest semantic core and the strongest extension model. Nulang’s best long-term bet is to become a semantic platform around actors + effects + capabilities, with multiple surface syntaxes (handwritten code, natural-language intent, AI-generated plans, JSON API, voice, visual blocks) converging on a single typed intermediate representation.
The current foundation does not support that convergence because the handwritten parser owns the AST and the compiler lowers it directly to bytecode. Before declaring v1.0, Nulang needs a compiler frontend rewrite around a small, explicit IR stack (Intent IR → AST → HIR → MIR), a real effect/capability solver, a workspace split, and the removal or postponement of half of the current feature checklist.
The following weaknesses are grounded in specific files and line numbers.
Compiler::compile_module in src/compiler.rs:188-202 lowers ast::AstModule directly to bytecode::CodeModule. There is no high-level IR (HIR), no mid-level IR (MIR), and no SSA form. This means:
- Optimizations cannot be expressed as passes over a stable IR.
- The LSP (
src/lsp/mod.rs) is forced to use regex heuristics because there is no resolved, typed tree to query. - A natural-language or AI frontend cannot target Nulang semantics without re-implementing the parser’s concrete syntax.
- Every new surface construct (e.g.
for,pipe,handle) must be compiled by adding a special case incompile_expr(src/compiler.rs:265-312).
Parser::parse_module in src/parser.rs:75-108 parses declarations sequentially and aborts on the first error. When a declaration fails, it falls back to parsing an expression and wrapping it in __main, which means a typo in one declaration can silently change the rest of the file into top-level expression mode. There is no synchronization, no "skip to next top-level keyword" recovery, and no diagnostic accumulation.
Parser::expect (src/parser.rs:1110-1120) constructs errors by formatting the debug representation of the expected token. NuError is a single-error enum (src/types.rs:457-505), and the pipeline uses ? everywhere (src/main.rs:174-231). The LSP therefore cannot provide diagnostics because the compiler stops at the first failure.
Expr in src/ast.rs:38-208 is a flat enum containing everything from Pipe, CapAnnotate, and TypeAnnotate to Send, Ask, Migrate, Spawn, and Emit. Surface-level conveniences such as the pipe operator (|>) are carried all the way to the compiler instead of being desugared. The AST also stores Type and EffectRow values directly inside nodes, so type-system data structures are entangled with syntax.
Every Expr variant carries a Span, but the semantic contexts that track variable usage do not. TypeContext::consume for linear variables uses Span::default() for its errors (src/types.rs:394-418). TypeChecker::infer_letrec unifies with Span::default() (src/typechecker.rs:966), and infer_handle unifies handler bodies with Span::default() (src/typechecker.rs:1666). effect_checker.rs has a hand-rolled expr_span helper (src/effect_checker.rs:1147-1182) precisely because spans are not stored uniformly.
Type::Function stores a single param: Box<Type> (src/types.rs:264-269). Multiple parameters are represented as a Type::Tuple wrapped around the parameter (src/typechecker.rs:484-488, src/typechecker.rs:1019-1034). This creates an impedance mismatch with the runtime calling convention, which passes arguments in consecutive registers (src/compiler.rs:510-524).
apply_subst_to_ctx in src/typechecker.rs:99-106 is a placeholder that clones the context without applying substitutions. TypeChecker::get_ctx_free_vars (src/typechecker.rs:1743-1750) returns only self.ctx_free_vars, initialized once and never updated. Because do_generalize (src/typechecker.rs:1728-1741) relies on this set, the implementation can both over-generalize and under-generalize variables.
effect_row_compatible in src/typechecker.rs:134-151 checks row compatibility by sorting debug string representations of effects and comparing sets. Open rows are handled with ad-hoc subset checks; row variables (Region) are never unified through the substitution machinery. effects.rs::unify_rows (src/effects.rs:28-92) silently adds missing effects rather than emitting real constraints.
CapabilityAnalyzer::infer_cap in src/effect_checker.rs:800-1091 computes capabilities by joining the capabilities of free variables captured by a lambda. It does not track aliases, assignments, or field mutations. LinearIso consumption is tracked in TypeContext by a string-based consumed: HashSet<String> (src/types.rs:352-425), without span information.
The bytecode ISA defines capability opcodes (CapChk, CapUp, CapDown, CapSend at src/bytecode.rs:132-137), but AGENTS.md:33 says these are MVP no-ops. The capability system is compile-time linting with no runtime enforcement, and the lint itself is unsound for non-trivial programs.
src/escape_analysis.rs is a 1,500-line bytecode-level escape analysis with tests, but AGENTS.md:120 notes it is dead code — never imported by compiler.rs, vm.rs, or jit/. The compiler performs no constant folding, no dead-code elimination, no inlining, and no copy propagation. Performance work is concentrated in the JIT tier, which covers only ~30 opcodes and falls back to interpretation for the rest.
Cargo.toml declares one crate (nulang) with 17 public modules (src/lib.rs). The intended feature set — AI SDK, workflow SDK, web framework, package manager, cloud platform, LSP — is documented in five multi-thousand-line design documents but lives in the same compilation unit as the runtime, GC, JIT, and Python bridge. This produces long compile times, tight coupling, and inability to version the language spec independently of the runtime.
Lexer::read_identifier accepts only ASCII alphanumerics (src/lexer.rs:152-153), and the test suite explicitly asserts that Unicode identifiers are rejected (src/lexer.rs:938-943). The language has no published grammar; indentation and semicolon rules are handled by ad-hoc helpers scattered through the parser (src/parser.rs:1147-1157).
parse_import in src/parser.rs:393-400 stores a string path and an empty items vector. Decl::Import is a no-op in the type checker (src/typechecker.rs:561-573). Decl::Module creates a nested namespace, but there is no name-resolution phase. Nulang currently has no real separate compilation, no package boundaries, and no enforced visibility.
TypeChecker::infer_send (src/typechecker.rs:1580-1609) only requires that the receiver has an Actor { ... } type; it does not check that the behavior name exists or that argument types match. Compiler::compile_send (src/compiler.rs:972-985) resolves the behavior by string matching and falls back to an out-of-bounds sentinel. Remote sends use behavior_id=0 as a placeholder (AGENTS.md:122).
The actor runtime is a Runtime god-object (src/runtime/mod.rs:66) that runs one step_actor at a time on the calling thread. The Scheduler uses Chase-Lev work-stealing deques (src/runtime/scheduler.rs:84) but the runtime never spawns worker threads; Scheduler::run_worker exists (src/runtime/scheduler.rs:257) but is unwired. This serializes all actor execution.
The NUL0 wire protocol (src/runtime/network.rs:17) has no versioning, no encryption, no authentication, no message deduplication, and no backpressure beyond TCP. The outbound channel is bounded to 1024 messages and silently drops on overflow (src/runtime/network.rs:700-703). Remote sends carry behavior_id = 0 resolved by name on the target (src/runtime/distributed.rs:665). Supervision is purely local; restart history is in-memory only (src/runtime/supervisor.rs:120).
The README still shows an agent { ... } DSL example (README.md:125-141) even though the DSL was removed in v0.7 (README.md:43). There is no Intent IR, no NL frontend, and no structured representation of "intent" separate from handwritten AST nodes. The current architecture cannot deliver on the "AI-native" goal without first solving the IR problem.
| Weakness | File evidence | Severity | Blocks v1.0? |
|---|---|---|---|
| AST is the only IR | src/compiler.rs:188-202 |
High | Yes |
| No parser error recovery | src/parser.rs:75-108 |
High | Yes |
| AST mixes syntax/types/caps | src/ast.rs:38-208 |
High | Yes |
| Lost spans in errors | src/types.rs:394-418, src/typechecker.rs:966 |
Medium | Yes |
| Single-parameter function types | src/types.rs:264-269 |
Medium | Yes |
| No-op substitution context | src/typechecker.rs:99-106 |
High | Yes |
| Broken effect-row unification | src/typechecker.rs:134-151, src/effects.rs:47-68 |
High | Yes |
| Capabilities erased / unsound | src/bytecode.rs:132-137, src/effect_checker.rs:800-1091 |
High | Yes |
| No optimizer; dead escape analysis | src/escape_analysis.rs:1, AGENTS.md:120 |
Medium | No |
| Single-crate monolith | Cargo.toml:1-45 |
Medium | No |
| ASCII-only lexer, no grammar | src/lexer.rs:152-153 |
Medium | Yes |
| No module/import semantics | src/parser.rs:393-400 |
High | Yes |
| Dynamic actor behavior typing | src/typechecker.rs:1580-1609 |
High | Yes |
| Single-threaded runtime | src/runtime/mod.rs:337, src/runtime/scheduler.rs:257 |
High | Yes |
| Distributed runtime not production-ready | src/runtime/network.rs:700 |
High | Yes |
| NL/AI convergence not architected | README.md:125-141, README.md:43 |
High | Yes |
These improvements are ranked by leverage on the 50-year goal, not by ease of implementation.
What: Define a small, typed, desugared HIR (name-resolved, no surface syntax) and an SSA-like MIR. Lower AST to HIR, run all semantic analysis on HIR, lower HIR to MIR, then optimize and codegen.
Difficulty: Hard.
Maintenance cost: Medium.
Why it matters: This enables every other improvement: optimization, LSP, NL/AI frontends, separate compilation, and alternate backends.
What: Replace the separate HM substitution pass, ad-hoc effect-row compatibility check, and capability join analysis with one solver producing substitutions for type variables, row variables, and capability variables.
Difficulty: Hard.
Maintenance cost: Medium.
Why it matters: The current effect and capability systems are unsound. A single solver is the only way to make the three subsystems coherent.
What: Replace the single-threaded Runtime::run_scheduler with a pool of worker threads each running Scheduler::run_worker, while preserving actor-isolation invariants.
Difficulty: Medium.
Maintenance cost: Low.
Why it matters: Today all actor execution is serialized on one thread. The Chase-Lev deque is already present but unused for true parallelism. Unlocking it is the single biggest throughput win.
What: Replace the behavior_id = 0 placeholder, silent drops, and unversioned NUL0 framing with a protocol carrying stable behavior IDs, delivery semantics, ACK/NACK, backpressure, and schema versioning.
Difficulty: High.
Maintenance cost: Medium.
Why it matters: The current protocol loses messages silently when the outbound channel is full (src/runtime/network.rs:700), cannot evolve, and cannot express at-least-once vs at-most-once.
What: Build a workflow engine on top of persistent actors: durable timers, activity workers, saga compensation, and deterministic replay.
Difficulty: Very High.
Maintenance cost: High.
Why it matters: Workflows are a major differentiator for a 50-year architecture because they turn "let it crash" into "resume exactly where you left off."
What: Move the frontend, IR, type solver, VM/JIT, runtime, and LSP into separate crates with explicit, acyclic dependency edges.
Difficulty: Medium.
Maintenance cost: Low.
Why it matters: Compile times, test isolation, and team scaling all improve. It also forces the architecture to be modular.
What: Either adopt a parser generator (e.g. lalrpop, chumsky, tree-sitter) or rewrite the recursive-descent parser with diagnostic accumulation and synchronization points. Preserve a formal grammar document.
Difficulty: Medium.
Maintenance cost: Low to Medium.
Why it matters: A language cannot ship with first-error abort. The LSP needs diagnostics, and users need useful error messages.
What: Replace dead escape_analysis.rs with MIR passes: constant folding, dead-code elimination, function inlining, common subexpression elimination, and escape analysis. Feed results to both the bytecode compiler and the JIT.
Difficulty: Hard.
Maintenance cost: Medium.
Why it matters: Performance claims depend on JIT heroics; a solid optimizer makes the language consistently fast and the JIT simpler.
What: Add OpenTelemetry-compatible tracing, Prometheus-style metrics, and structured logging throughout the runtime.
Difficulty: Low.
Maintenance cost: Low.
Why it matters: There is almost no observability today. Scheduler stats, resolver stats, and GC stats exist but are not exported.
What: Replace regex-based inlay hints with a persistent, incremental compiler database (CompileDb) powering diagnostics, hover, goto-definition, find-references, refactoring, and AI-assisted code actions.
Difficulty: High.
Maintenance cost: Medium.
Why it matters: The current LSP (src/lsp/mod.rs) is explicitly MVP and regex-based. A credible developer experience requires a semantic IDE.
These features either do not work, are dead code, or add complexity the core cannot support yet.
- Python bytecode opcodes (
0x94-0x9B). Reserved but unused after the v0.12 audit moved Python to the native-actor boundary. Remove from the ISA; route Python throughperform Python.call(...)only. src/escape_analysis.rsas it stands. Dead code with no consumer. Delete or rewrite as a real MIR pass.- Capability VM opcodes (
CapChk,CapUp,CapDown,CapSend). No-ops today. Remove; reintroduce only when capabilities have sound, enforced semantics. - Agent DSL /
agentdeclarations. Removed in v0.7 but still referenced in docs. Ensure it is gone from docs, tests, and AST. - Actor migration (
Migrateexpression and opcode). Semantics undefined; compiler falls back to default behavior indices. Postpone until the distributed runtime is hardened. - CRDT actor state model (
StateModel::Crdt). Wiring into actor state fields is incomplete and untested. Postpone until persistence and CRDT semantics are formalized. - SIMD auto-vectorization in the JIT. Impressive but premature for an alpha with only ~30 JIT-supported opcodes. Keep behind a feature flag.
- Regex-based LSP inlay hints. Postpone shipping LSP features until HIR-based implementation exists.
- Dual-region generational heap + escape analysis pair. Already reverted. Do not reintroduce without formal proof of safety.
- Manual CLI argument parser. Replace
src/main.rs:44-83withclapor similar. - WASM component compilation. Listed in
ROADMAP.mdandARCHITECTURE.mdas v1.0 but too risky to ship around a complex GC/JIT. Defer to v1.2+. - Managed cloud / Nulang Cloud. Defer to v1.5+; focus on self-hosted deployments for v1.0.
- Hot code reloading. Interacts dangerously with durable state schemas. Defer.
- Web framework (
phoenix-nl). Provide only stdlib HTTP primitives in v1.0; ship the framework as a community package after v1.0.
These choices align with long-lived language design principles and should be preserved and deepened.
- Actor model with location-transparent addressing. Erlang/OTP has survived for decades because actors + message passing + failure isolation are robust abstractions.
- Per-actor heap + ORCA-style reference counting. Shared-nothing heaps are the only realistic foundation for predictable latency in a distributed actor system.
- Register-based bytecode VM with NaN-boxed values. Compact instructions, dense values, and a simple dispatch loop are durable implementation choices.
- Algebraic effects as a core abstraction. Effects make side effects, AI tool calls, IO, and distribution explicit and testable.
- Capability-based reference permissions. Even if the current implementation is weak, the idea of
iso/val/ref/box/tagis a proven way to control sharing across actors. - First-class functions and closures. Fundamental for higher-order programming and effect handlers.
- Pattern matching and sum/record types. The right data-modeling primitives for a typed functional language.
- Hindley-Milner type inference as the default. Full inference is a strong usability bet.
- Effect-driven AI/tool integration. Using effects (
perform LLM.generate(...),perform Python.call(...)) instead of a special DSL keeps AI as a first-class capability without polluting syntax. - Distribution primitives (
spawn, remotesend, CRDTs, gossip). The ambition to run actors across nodes with the same semantics as local actors is durable.
Nulang is a collection of good bets in search of an architectural center. The path to 50-year relevance is not to add more features, but to refactor around a semantic core: a clean IR stack, a unified type/effect/capability solver, a workspace split, a true M:N scheduler, a production distributed protocol, and the removal of dead or premature features. If that refactor is executed, the durable ideas in section 5 will have a stable foundation. If it is not, the codebase will become an increasingly fragile bag of demo features.