Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 101 additions & 102 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
# Changelog
# Changelog

## [Unreleased]

## [5.9.0] - 2026-08-31

### Added

- **#170: `fs.read_bytes` / `fs.write_bytes` — binary file I/O.**

`std:fs` could only read and write UTF-8 text: `write_file` opens with
`encoding='utf-8'` and there was no binary mode, so a Nodus program could not
write a compiled artifact. The Runtime Readiness audit recorded that as a
Stage 3 bootstrap gap — to write a Nodus compiler in Nodus, the compiler has to
be able to write bytecode files.

**A byte sequence is a list of integers 0–255, not a new value type.** The issue
listed a `Bytes` type as "consider" and it is deliberately not taken: a real
byte type needs indexing, slicing, concatenation, equality, a literal syntax and
JSON serialisation before it is usable, and a list of ints already has all six.
If `Bytes` arrives later it can be a representation change behind the same two
builtins.

`write_bytes` validates every element before opening the file, so a refused
write leaves no partial file. Out-of-range values raise a `value` error naming
the index; non-integers — including `true`, which is an `int` in Python and is
not a byte here — raise a `type` error.

Both go through **both** filesystem mechanisms, which is the part that needed
care: `_ensure_path_allowed` (the `allowed_paths` jail and the Floor) *and*
`BUILTIN_CAPABILITIES` (what a `CapabilityPolicy` can see). #467 was a builtin
wired to the first and not the second — "the map, not the chokepoint" — and it
was invisible to a policy while looking confined.

### Fixes

- **#704: the bytecode cache now notices a content change the mtime does not.**
Expand Down Expand Up @@ -39,6 +69,76 @@
survived compilation but not serialization). Those were about *what* was cached;
this one was the key itself.


- **#696: a closure *returned* from a module now runs against its own chunk too.**

The mirror of #691, and it needed a different answer. #691 fixed closures going
*into* a module; every context source that fix uses records something a call is
still inside of — a `_ClosureProxy` wrapped for an argument, a live cross-module
frame, a caller VM. By the time a **returned** closure is called, the frame has
been popped and there is no caller VM, so all three are empty and the closure
ran at its own address in whatever chunk happened to be loaded.

Same five-way symptom spread as #691, for the same reason — the symptom is
whatever sits at that address. Measured one repro each: `Method calls are only
supported on records`, `run_workflow(workflow) expects a workflow`, `Cannot add
int and string`, `Stack underflow`, `'NoneType' object is not subscriptable`,
and a stateful factory closure that **printed nothing at all**.

This did not need a workflow or a coroutine: `let f = m.plain_maker()` in
`fn main()` was enough, which makes a factory function — an ordinary thing for
a module to export — unusable.

The fix resolves rather than marks. Marking a closure on the way out would mean
a hook at each exit *and* a walk of returned lists, maps and records — the case
#339 found the entry side had missed. Instead `VM._foreign_closure_origin` asks
which of the modules this VM can **reach** owns the closure's `FunctionInfo`: a
module holds its `functions` table for its whole life, so the answer survives
every frame being gone. Reachability rather than a process-wide registry, which
would be the module-scope state shape behind #185 and #390.

`VM.module_ctx` is now the single definition of a module's execution context,
used both by `_try_enter_module_call` on the way in and by the resolution
above, so the two cannot drift.

- **#691: a callback handed to an imported module's function now runs against
its own chunk, wherever the call is made from.**

A `Closure` is an address plus its upvalues, and the address indexes the chunk
it was compiled from. A module function reached from `fn main()` runs in a
detached VM, which wrapped closure arguments in a `_ClosureProxy` on the way in
and dispatched them back correctly. A module function reached from inside a
scheduler-managed coroutine takes the #105 fast path instead — the module's
code is swapped into the *running* VM — and nothing there was wrapped or
checked, so the callback's address was executed against the module's
instructions. **A workflow step body is always a coroutine**, which is why the
construct worked at top level and failed inside a step.

Five symptoms came out of one construct, depending only on what happened to sit
at that address:

| Symptom | Shape |
|---|---|
| step truncates, `failed: []`, `steps: {}`, run reports success | module defines one function |
| `Stack underflow` | module defines two |
| `Cannot call non-function: nil` | callback is a named top-level `fn` |
| `Iterator is not supported` | callback reached through the iterator protocol |
| callback silently never runs | callback wrapped in a `coroutine()` |

`retry.until` (#466) is the feature this blocked: a `std:retry` function whose
documented home is a step body.

`VM._foreign_closure_origin` answers "which context does this closure need, if
not the one loaded" once, and both sites that jump to a closure's address —
`call_closure` and `run_closure` — consult it. `builtin_coroutine_create` and
`builtin_spawn` had their own version of the question and now ask the same one;
theirs assumed a detached VM was the only way to be running foreign code, which
is precisely the assumption that hid this. Origin is resolved by asking which
saved context *owns* the closure's `FunctionInfo`, not by taking the nearest
boundary — nearest is wrong as soon as a closure is passed through two modules.

Not a regression: v5.7.1 and every earlier release behave identically.

### Performance

- **#702: ~9.6x recovered on PyPy, by moving five constants off the instance.**
Expand Down Expand Up @@ -80,35 +180,6 @@
still read correctly, are absent from the instance dict, and that writing one
creates an ordinary per-instance attribute rather than leaking across VMs.

### Added

- **#170: `fs.read_bytes` / `fs.write_bytes` — binary file I/O.**

`std:fs` could only read and write UTF-8 text: `write_file` opens with
`encoding='utf-8'` and there was no binary mode, so a Nodus program could not
write a compiled artifact. The Runtime Readiness audit recorded that as a
Stage 3 bootstrap gap — to write a Nodus compiler in Nodus, the compiler has to
be able to write bytecode files.

**A byte sequence is a list of integers 0–255, not a new value type.** The issue
listed a `Bytes` type as "consider" and it is deliberately not taken: a real
byte type needs indexing, slicing, concatenation, equality, a literal syntax and
JSON serialisation before it is usable, and a list of ints already has all six.
If `Bytes` arrives later it can be a representation change behind the same two
builtins.

`write_bytes` validates every element before opening the file, so a refused
write leaves no partial file. Out-of-range values raise a `value` error naming
the index; non-integers — including `true`, which is an `int` in Python and is
not a byte here — raise a `type` error.

Both go through **both** filesystem mechanisms, which is the part that needed
care: `_ensure_path_allowed` (the `allowed_paths` jail and the Floor) *and*
`BUILTIN_CAPABILITIES` (what a `CapabilityPolicy` can see). #467 was a builtin
wired to the first and not the second — "the map, not the chokepoint" — and it
was invisible to a policy while looking confined.

### Tooling

- **Two guards over the filesystem builtin surface, both driven off the named set
rather than a list written in the test (#170).**
Expand Down Expand Up @@ -180,78 +251,6 @@
#411 compiler prefix; `BUILD_MAP` refuses a `bool` key; conditional jumps pop
whether or not they jump; an empty record is truthy while an empty map is not.

### Fixes

- **#696: a closure *returned* from a module now runs against its own chunk too.**

The mirror of #691, and it needed a different answer. #691 fixed closures going
*into* a module; every context source that fix uses records something a call is
still inside of — a `_ClosureProxy` wrapped for an argument, a live cross-module
frame, a caller VM. By the time a **returned** closure is called, the frame has
been popped and there is no caller VM, so all three are empty and the closure
ran at its own address in whatever chunk happened to be loaded.

Same five-way symptom spread as #691, for the same reason — the symptom is
whatever sits at that address. Measured one repro each: `Method calls are only
supported on records`, `run_workflow(workflow) expects a workflow`, `Cannot add
int and string`, `Stack underflow`, `'NoneType' object is not subscriptable`,
and a stateful factory closure that **printed nothing at all**.

This did not need a workflow or a coroutine: `let f = m.plain_maker()` in
`fn main()` was enough, which makes a factory function — an ordinary thing for
a module to export — unusable.

The fix resolves rather than marks. Marking a closure on the way out would mean
a hook at each exit *and* a walk of returned lists, maps and records — the case
#339 found the entry side had missed. Instead `VM._foreign_closure_origin` asks
which of the modules this VM can **reach** owns the closure's `FunctionInfo`: a
module holds its `functions` table for its whole life, so the answer survives
every frame being gone. Reachability rather than a process-wide registry, which
would be the module-scope state shape behind #185 and #390.

`VM.module_ctx` is now the single definition of a module's execution context,
used both by `_try_enter_module_call` on the way in and by the resolution
above, so the two cannot drift.

- **#691: a callback handed to an imported module's function now runs against
its own chunk, wherever the call is made from.**

A `Closure` is an address plus its upvalues, and the address indexes the chunk
it was compiled from. A module function reached from `fn main()` runs in a
detached VM, which wrapped closure arguments in a `_ClosureProxy` on the way in
and dispatched them back correctly. A module function reached from inside a
scheduler-managed coroutine takes the #105 fast path instead — the module's
code is swapped into the *running* VM — and nothing there was wrapped or
checked, so the callback's address was executed against the module's
instructions. **A workflow step body is always a coroutine**, which is why the
construct worked at top level and failed inside a step.

Five symptoms came out of one construct, depending only on what happened to sit
at that address:

| Symptom | Shape |
|---|---|
| step truncates, `failed: []`, `steps: {}`, run reports success | module defines one function |
| `Stack underflow` | module defines two |
| `Cannot call non-function: nil` | callback is a named top-level `fn` |
| `Iterator is not supported` | callback reached through the iterator protocol |
| callback silently never runs | callback wrapped in a `coroutine()` |

`retry.until` (#466) is the feature this blocked: a `std:retry` function whose
documented home is a step body.

`VM._foreign_closure_origin` answers "which context does this closure need, if
not the one loaded" once, and both sites that jump to a closure's address —
`call_closure` and `run_closure` — consult it. `builtin_coroutine_create` and
`builtin_spawn` had their own version of the question and now ask the same one;
theirs assumed a detached VM was the only way to be running foreign code, which
is precisely the assumption that hid this. Origin is resolved by asking which
saved context *owns* the closure's `FunctionInfo`, not by taking the nearest
boundary — nearest is wrong as soon as a closure is passed through two modules.

Not a regression: v5.7.1 and every earlier release behave identically.

### Tooling

- **`CLAUDE.md` trimmed from 1,889 to 1,619 lines; per-repo companion detail moved
to `docs/ecosystem/COMPANION_REPOS.md`.**
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1369,7 +1369,7 @@ Importing `nodus_lang_workflow` before `nodus` in a fresh process is safe. Do no

## SemVer policy

The current published version is **v5.8.0** (live on PyPI, published 2026-08-30).
The current published version is **v5.9.0** (live on PyPI, published 2026-08-31).
Two files must stay in sync — `src/nodus/support/version.py` and `pyproject.toml`.
If they disagree, fix that before anything else.

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@
> [the migration note](https://github.com/Masterplanner25/Nodus/blob/main/docs/migration/v5.0-deny-by-default.md) and
> [#405](https://github.com/Masterplanner25/Nodus/issues/405).

**Recent:** 5.8.0 is about work that is already in motion: a running workflow or task can be **stopped** (`nodus workflow cancel`, `cancel(t)`, `wait(t)`), and a call that returned the *wrong thing* rather than failing can be **retried against a predicate** (`retry.until`) — the failing result carried into the next attempt, under a bound that always applies.
**Recent:** 5.9.0 is about programs doing what they say. Three defects are fixed
that all failed the same way — **silently, while reporting success**. A closure
handed to an imported module's function, or returned from one, ran at its own
address in the wrong chunk: inside a workflow step that truncated the step and
reported no failures. And the bytecode cache keyed only on path and modification
time, so an edit landing inside the filesystem's timestamp resolution ran the
*previous* program. `std:fs` also gains binary I/O (`fs.read_bytes`,
`fs.write_bytes`), which is what a Nodus program needs to write a compiled
artifact.

An installed Nodus could not tell you where its own documentation was. The wheel
shipped code and the stdlib; the guide, the machine-readable index and the agent
Expand Down
6 changes: 3 additions & 3 deletions docs/governance/ECOSYSTEM_READINESS_ASSESSMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
## Summary

The Nodus ecosystem is **published, real, and awaiting real-world validation.**
nodus-lang is at **v5.8.0** (current stable on PyPI). The ecosystem spans **36 standalone
nodus-lang is at **v5.9.0** (current stable on PyPI). The ecosystem spans **36 standalone
companion packages** — 37 PyPI projects counting nodus-lang itself — all published under
Masterplanner25. The coordinated launch is
complete. No package has yet seen significant real-world traffic; that is the honest
Expand All @@ -29,7 +29,7 @@ findings in any eval cycle.

## Assessment: nodus-lang (core)

**Current version:** 5.8.0 (published to PyPI 2026-08-30)
**Current version:** 5.9.0 (published to PyPI 2026-08-31)
**Previous published:** 3.0.2 (last pre-v4 release)

| Dimension | Level |
Expand All @@ -38,7 +38,7 @@ findings in any eval cycle.
| Implementation completeness | **Complete for v4.0 scope** — core language, VM, embedding API, coroutine scheduler, goals/workflows DSL, AI-native stdlib, full security sandbox all shipped |
| Operational readiness | **Published and gate-validated** — CLI, embedding API, 2,839 tests (coverage last measured at 76.8% on 2026-08-07, so a floor rather than a current reading), lint gate, doc-vs-code gate, Gate 10 creator validation all pass. Not yet proven under real production traffic. |
| Stability commitment | **Beta classifier (PyPI)** — stable surfaces documented in LANGUAGE_STABILITY_INDEX.md; classifier upgrade to Production/Stable deferred until two consecutive minor releases with clean evals |
| Publication status | **Published** — v5.8.0 live on PyPI |
| Publication status | **Published** — v5.9.0 live on PyPI |

**Composite label:** Published / Stable baseline

Expand Down
2 changes: 1 addition & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Nodus compiles to bytecode and runs on a deterministic stack-based VM with a coo
scheduler. It embeds in Python via `NodusRuntime`, which denies subprocess/network/env by default, and can sandbox execution, enforce resource
limits, and wire tool registries.

**Current version:** v5.8.0 — published to PyPI 2026-08-30. Install: `pip install nodus-lang`. Full 36-package companion ecosystem live; unified install: `pip install nodus-sdk[agent,sql,fastapi]`.
**Current version:** v5.9.0 — published to PyPI 2026-08-31. Install: `pip install nodus-lang`. Full 36-package companion ecosystem live; unified install: `pip install nodus-sdk[agent,sql,fastapi]`.

---

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Shawn Knight — creator of Nodus and architect of the Masterplan Infinite Weave

## Package

- PyPI: `nodus-lang` (v5.8.0 — current stable, published 2026-08-30)
- PyPI: `nodus-lang` (v5.9.0 — current stable, published 2026-08-31)
- Install: `pip install nodus-lang`
- Full 36-package companion ecosystem live: `pip install nodus-sdk[agent,sql,fastapi]`
- Requires Python >= 3.10
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "nodus-lang"
version = "5.8.0"
version = "5.9.0"
description = "An orchestration DSL and embedded runtime for building agentic hosts"
authors = [
{ name = "Shawn Knight" }
Expand Down
4 changes: 2 additions & 2 deletions skills/nodus.skill
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: nodus
version: "5.8.0"
version: "5.9.0"
description: >
Use this skill whenever writing, editing, or debugging Nodus (.nd) code.
Triggers on: any .nd file, workflow DSL tasks, the `nodus` CLI, importing
Expand All @@ -26,7 +26,7 @@ The most common failure mode: writing Python-shaped Nodus. A script full of
functions and for-loops that happens to parse is not idiomatic Nodus. Reach for
`workflow { step … }` whenever there is sequencing, dependency, or retry involved.

**Package version is 5.8.0. Bytecode version is 4** — unchanged since v1.0, so a major
**Package version is 5.9.0. Bytecode version is 4** — unchanged since v1.0, so a major
bump does not imply recompilation. Installed via `pip install nodus-lang`.

Run `nodus docs` for the guide, the machine-readable index and this skill's source,
Expand Down
2 changes: 1 addition & 1 deletion skills/project-AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Language

This project uses **Nodus** (`nodus-lang 5.8.0`).
This project uses **Nodus** (`nodus-lang 5.9.0`).

Install: `pip install nodus-lang`

Expand Down
2 changes: 1 addition & 1 deletion skills/project-CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Language

This project uses **Nodus** (`nodus-lang 5.8.0`).
This project uses **Nodus** (`nodus-lang 5.9.0`).

Install: `pip install nodus-lang`

Expand Down
2 changes: 1 addition & 1 deletion src/nodus/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Shawn Knight — creator of Nodus and architect of the Masterplan Infinite Weave

## Package

- PyPI: `nodus-lang` (v5.8.0 — current stable, published 2026-08-30)
- PyPI: `nodus-lang` (v5.9.0 — current stable, published 2026-08-31)
- Install: `pip install nodus-lang`
- Full 36-package companion ecosystem live: `pip install nodus-sdk[agent,sql,fastapi]`
- Requires Python >= 3.10
2 changes: 1 addition & 1 deletion src/nodus/support/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Version metadata for Nodus."""

__version__ = "5.8.0"
__version__ = "5.9.0"
VERSION = f"Nodus {__version__}"
Loading
Loading