Skip to content

feat(runtime): versioned run receipts with one canonical serializer - #28

Merged
AetherAI3 merged 11 commits into
mainfrom
feat/nano-st1-receipts
Aug 19, 2026
Merged

feat(runtime): versioned run receipts with one canonical serializer#28
AetherAI3 merged 11 commits into
mainfrom
feat/nano-st1-receipts

Conversation

@AetherAI3

Copy link
Copy Markdown
Owner

The bug this started from

Backtester.verify_replay advertised "bit-identical replay… byte-for-byte identical reports" and compared two Python dicts:

{"approved": True} == {"approved": 1}   # True
{"x": 0.0}         == {"x": -0.0}       # True

A host DecisionGate backed by JSON, a database, or numpy — one whose values change type between runs, which is the realistic case — passed as bit-identical. nano ... --verify had the same hole. Both now compare canonical bytes and name the drifted paths.

Also found: content_hash ran with allow_nan defaulted true, so a non-finite float would be hashed over the non-JSON tokens NaN / Infinity. The receipt encoder refuses instead, naming the path.

What ships

  • nano/runtime/receipt.py — one canonical serializer, the single source of truth for turning a run into bytes. Sorted keys, explicit separators, ASCII-only, no NaN/Inf, non-dict mappings and reference cycles refused with a path.
  • A versioned receipt carrying executable identity (Nano version, IR version, module hash, source hash) separately from provenance — following content_hash's own precedent that a document cannot commit to its own digest.
  • verify_run / differences — drift is detected and located.
  • Four golden files plus tests/regen_goldens.py.
  • docs/receipts.md — what external consumers may depend on, and what is explicitly unstable.
  • tests/test_determinism_guards.py — an AST scan locking the no-network / no-clock / no-entropy / no-third-party-import claims that previously held by code review alone.

Byte-stability, attacked rather than asserted

Identical receipt_digest across PYTHONHASHSEED ∈ {0, 1, 987654321, 4294967295} × {3.11.9, 3.13.14} × permuted source-level dict construction order — compared with hashlib, not ==.

Float conventions are documented and executable: -0.0, 0.1+0.2, 1e308, 5e-324, 1e16, integral floats, NaN/±Inf refusal. Key order is sorted by Unicode code point, with an explicit note that this differs from RFC 8785 / JCS (UTF-16 code-unit order), so a JCS-based reimplementation will not match.

Not claimed

No signature or authenticity field. Unsigned receipts claim reproducibility only, stated in the docs and asserted by a test. Protocol-C signing stays optional and outside deterministic core behavior — the base receipt is fully constructible and byte-stable with aether-protocol-c not installed, and there is a test for that.

Behavior change

verify_replay now raises ReceiptError — a ValueError, not the documented ReplayDivergence — for a host whose gate returns numpy.bool_ or decimal.Decimal. Such a host may be perfectly deterministic and previously passed. This is the fix working as intended, but it is a real change: docs/receipts.md §4 carries an error-type table and an explicit "except ReplayDivergence will not catch a ReceiptError", plus a behavior-change note.

Invariants

Zero-line diff on nano/runtime/vm.py, effects.py, interpreter.py, nano/ir/module.py, nano/ir/schema.py. KNOWN_EFFECTS and EFFECT_ORDER unmoved. stdlib only. ReplayDivergence moved to nano.runtime.receipt and is re-exported, so except nano.bridge.ReplayDivergence still works — all three names are the same object.

Baseline compatibility byte-proven: every library strategy serialized on both trees (module hash, source hash, full IR document, VM run, interpreter run) to an identical digest.

Verification

  • 555 passed, 2 skipped on Python 3.11.9 and 3.13.14, rebased onto current main.
  • 28 + 12 mutations, all red. Two initially came back green and were fixed — including one where the drifting-gate test changed the approval value rather than only its type, so it was not actually proving the byte-vs-dict distinction it claimed to.
  • Reviewed adversarially three times by a reviewer that did not write the patch. It found a Windows CRLF framing violation the lane's own test was structurally blind to (capsys does no newline translation) — the guard now runs the CLI in a real subprocess with no text=True. It also found a regression a fix round introduced, where the deep-copy of host ran before validation and swallowed the pathed error.
  • The determinism scan runs green over the whole merged tree, including upstream's new 1,040-line nano/watchdog package it has never seen.
  • Wheel built and CI's exact asset verifier run: 83 expected assets, none missing.

Merge order

Rebased from 811d6c7 onto current main. Takes 1.0.3, so it should merge after #27 (1.0.1) and the library PR (1.0.2). If the order changes I will re-bump before merge.

Conflicts resolved during rebase: nano/runtime/__init__.py and nano/cli/commands.py — upstream's widened interpreter import (SignalFrame) kept alongside the receipt exports. The rebase also surfaced that tests/test_cli.py and upstream's tests/test_watchdog.py both hardcode the package version and break on any bump; both now derive from nano.__version__.

🤖 Generated with Claude Code

AetherAI3 and others added 11 commits August 19, 2026 16:37
A run already produced the ordered log an audit needs; it did not produce a
document. `nano/runtime/receipt.py` adds one: `receiptVersion` 1, a fixed
serialization, and a written promise about which bytes stay put.

`canonical_bytes` is the single source of truth for turning a run into bytes —
sorted keys, `separators=(",", ":")`, `ensure_ascii=True` encoded as ASCII, no
line terminator, non-finite floats refused, absent members omitted rather than
nulled. It continues `NanoModule.content_hash`'s existing convention rather than
inventing a second one; the deliberate additions are `allow_nan=False` and the
explicit ASCII encode.

The receipt separates executable identity (nanoVersion, irVersion, compiler,
moduleHash) from provenance (sourceHash — unverified) and from host-supplied
context, which is the only place a wall clock may appear. Unsigned receipts
claim reproducibility, not authenticity; Protocol-C signing stays optional and
outside the deterministic core.

Fixes a real hole while it is here. `Backtester.verify_replay` and
`nano replay --verify` claimed bit-identical replay while comparing Python
dictionaries, and `{"approved": True} == {"approved": 1}` and
`{"x": 0.0} == {"x": -0.0}`. A gate whose answer changed type between runs
passed. Both now compare canonical bytes and report the drifted paths.

- `nano replay --report receipt` emits the artifact as one JSON Lines record.
  `--report json` is untouched.
- `ReplayDivergence` moves to the runtime and is re-exported from the bridge,
  so existing `except nano.bridge.ReplayDivergence` still works.
- Four golden receipts checked in under `tests/golden/`, covering intents,
  params, an empty frame, and a recorded reasoning provider with escalations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ST scan

The package documented four properties in prose — no network, no ambient clock,
no ambient randomness, no mandatory third-party dependency — and all four held
by code review alone. Prose does not go red.

`tests/test_determinism_guards.py` walks every module under `nano/` with `ast`
and fails on a network or entropy import, on a call that samples an ambient
clock or the environment, and on any third-party import other than the one
optional `aether_protocol_c` in `nano/bridge/provenance.py`. A grep would match
a comment and miss `from a import socket as s`.

`time` and `datetime` stay importable: `nano/data/frames.py` parses ISO-8601 out
of a CSV, which is reading data, not reading a clock. What is banned is
sampling.

Two cross-process guards go with it. A receipt digest is computed under four
different `PYTHONHASHSEED` values and must not move — a single-process run
cannot see a `set` or an unsorted `dict` leaking into the artifact. And a fresh
interpreter that imports only the receipt path must pull in neither
`aether_protocol_c` nor `nano.bridge`, which is what makes "a base receipt is
constructible with Protocol-C absent" a fact rather than an intention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`docs/receipts.md` documents the canonical serialization rule by rule, the
receipt's four sections, the timestamp convention, drift detection, and the
boundary between reproducibility and authenticity.

The stability contract is explicit in both directions. Depend on the
serialization rules, the section names, `moduleHash`/`frameHash` as content
addresses, absent-means-absent, and array order. Do not depend on `log` entry
`detail` strings, node ids, the set of event names, `host`, or anything in
`nano replay --report json`.

Linked from the docs index beside Architecture and Status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`console.say` goes through a text wrapper, which rewrites every \n to
os.linesep. On Windows that meant `nano replay --report receipt` emitted the
canonical bytes followed by \r\n — two framing bytes where docs/receipts.md §1
promises exactly one, and where it promises that byte is not digested. A
consumer following the documented recipe and digesting everything but the last
byte got a mismatch.

`Console.emit` writes bytes to the underlying buffer with no encoding and no
newline translation, falling back to the text sink when a test injected an
in-memory stream.

The existing test could not have caught this: capsys captures into an in-memory
buffer that performs no translation, so it saw one \n either way. The new guard
runs `python -m nano.cli` in a real subprocess with capture_output and no
text=True, asserts the exact bytes, asserts the output does not end \r\n, and
walks the documented digest-the-file-minus-one-byte recipe.

Also closes the untested half of this branch's own bug fix. The claim was that
dict-equality replay checking was broken in both `Backtester.verify_replay` and
`nano replay --verify`; only the first had a test, and reverting the second to
`again != receipt` left the suite green. It now has one, built on two results
that are dict-equal and differ only in the JSON type of one field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t encode

Tightening `verify_replay` to compare bytes made a previously-working
deterministic host fail, and the failure was unhelpful. A numpy-backed gate
returning np.bool_(True) — same value every run — now raises ReceiptError, which
is a ValueError, so `except ReplayDivergence` misses it. The message read "bool
is not canonically encodable (allowed: ... boolean ...)", because
type(np.bool_(True)).__name__ is "bool". It looked like a bug in the encoder.

Types outside builtins are now named module-qualified, so the message says
numpy.bool_ or decimal.Decimal — the latter being what any NUMERIC column hands
back. The two exception types and the behavior change are written into
verify_run's docstring and docs/receipts.md §4, with the fix at the gate
boundary (bool(value), float(value)). The tightening stays: a comparison that
holds True == 1 cannot underwrite a claim about bytes.

Three holes where _check accepted values json.dumps then rejected, defeating its
whole purpose of failing with a path:

- A MappingProxyType passed the Mapping arm and raised a bare TypeError
  pointing at nothing. Only dict counts as an object now.
- A self-referential `host` raised RecursionError. There is a cycle guard.
- A lone surrogate survived ensure_ascii as \udXXX and then failed to decode
  anywhere, quietly breaking the "survives any transport" promise. Non-ASCII
  strings are validated; the cost is paid only when a string is not ASCII, so
  valid international text still encodes.

`intents` and `log` are read directly rather than through getattr defaults —
both result types define them, so a default would turn a future rename into a
receipt silently reporting zero intents. Only the two fields ExecutionResult
genuinely lacks stay defaulted. `host` is deep-copied, so a caller's nested
structure cannot keep aliasing into a receipt that has already been digested.

`differences` documents that it descends into a length-mismatched array as well
as reporting the length, which had no test. The three `or ("(identical
structure, different bytes)",)` fallbacks are deleted: _diff mirrors the encoder
exactly, so they were unreachable.

Simplification, per the brief. The 75-line module docstring restated
docs/receipts.md §1-§5 near-verbatim and had already co-drifted with it — both
carried the same wrong sort rule. It is now 21 lines that point at the doc.
`canonical_text` is gone as public API; its one call site was the CLI, which
needs bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eipt shape

The AST scan advertised more than it enforced. A probe module doing seven
ambient reads left the suite green:

  import datetime; datetime.datetime.now()   # base is an Attribute, not a Name
  import time as t; t.time()                 # no alias resolution
  from time import time; time()              # no Attribute node at all
  from os import urandom; urandom(8)
  os.environ[...] / os.environ.get(...)      # only os.getenv was banned
  __import__("socket")
  importlib.import_module("socket")

The first is the most common way to read a clock in Python. Every reference is
now normalised to a dotted path through a per-module alias map before matching,
so `import datetime` + `datetime.datetime.now` and `from datetime import
datetime` + `datetime.now` are one rule. Dynamic imports get their own check,
since either one routes around every module-level rule. A guard-on-the-guard
proves the resolver actually sees each shape, so a resolver that returned None
for everything could not make the scans pass on an empty list.

The docstring also claimed a grep "misses `from a import socket as s`" — a grep
for socket matches that line fine. Corrected to what an AST walk actually buys:
no comment or docstring matches, alias resolution, dotted-chain flattening.

tests/regen_goldens.py replaces the __main__ block. The documented command
`py -3.11 tests/test_receipts.py` did not work — running a file in tests/ puts
tests/ on sys.path, not the repository root, so `import nano` failed. That
command is load-bearing at a version bump, since identity.nanoVersion is part of
the artifact. The script inserts the root itself, so it works on every shell.

Four conventions this branch decided but never tested — each one survived being
reverted:

- `sorted(frame.signals)`, or the artifact inherits CSV column order.
- `list(module.effects)`, declared order, so the receipt agrees with the
  moduleHash printed beside it. `sign.emit` before `log.append` is canonical
  EFFECT_ORDER and is not sorted order, which is what makes them tellable apart.
- Key order is by Unicode code point, not "byte-wise over the escaped form" as
  §1 claimed for one revision. `é` sorts after `a`, not before. Also documented
  as the point where Nano departs from RFC 8785 / JCS.
- The receipt's member names, pinned per section. The goldens cannot guard the
  shape: they are regenerated as routine, so a new member could ride along in
  that regeneration unnoticed. Editing the pin is the act that means
  receiptVersion has to move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The L12 deep-copy fix silently undid the L10 path-naming fix for `host` — the
one section where arbitrary caller data actually lands, and therefore the one
place the validator mattered most.

`copy.deepcopy(dict(host))` ran before `_check`, so an uncopyable value escaped
as a bare TypeError naming nothing, which is exactly the failure `_check` exists
to replace. host={"lock": threading.Lock()} reported `ReceiptError: /host/lock`
before the fix round and `TypeError: cannot pickle '_thread.lock' object` after
it, while the identical value at a non-host path still reported correctly. The
validator worked everywhere except where it was needed.

Now: snapshot, check, then copy. After `_check` only canonical types remain and
all of them copy trivially, so the copy can no longer be the thing that fails.
docs/receipts.md §7 said "host is not validated beyond being canonically
encodable", which had quietly become false; it now states the order and what it
buys.

The regression was invisible to every other test because every other section is
built from canonical types already. The new test pins the path-carrying error
for both an uncopyable value and an un-encodable one, and pins the non-host
asymmetry that made this easy to miss.

Also closes a matcher hole in the AST scan's guard-on-the-guard: it exercised
the resolver but never `_banned_prefix`, so `return False` left both ambient
scans asserting [] == [] with the suite green. Positive and negative controls
now cover both halves, so neither "always False" nor "always True" passes.

Two cheap evasion shapes taken while here, since both were one condition:
`getattr(<imported module>, "name")` is flagged like `__import__` (only when the
target resolves to an import, so ordinary duck typing is untouched), and
subprocess / platform / getpass / pwd / grp join the module lists. The remaining
shapes need dataflow analysis; the docstring says so, because this is a guard
and not a static analyser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`identity.nanoVersion` is part of the receipt, so the four golden files move on
every version bump by design — pinning the real version is what proves
executable identity is captured. Regenerated with
`py -3.11 tests/regen_goldens.py`; the pinned `moduleHash`, `sourceHash` and
`frameHash` literals are version-independent and did not move, and the
per-section shape pin confirms no format change rode along.

Two tests hardcoded the package version and fail on any bump: the CLI version
test, and the watchdog receipt test's `nano_version` assertion. Both now derive
from `nano.__version__`, so they assert the invariant rather than a literal
that goes stale every release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AetherAI3
AetherAI3 force-pushed the feat/nano-st1-receipts branch from 831f997 to 876d605 Compare August 19, 2026 20:52
@AetherAI3
AetherAI3 merged commit 4d75d59 into main Aug 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant