feat(runtime): versioned run receipts with one canonical serializer - #28
Merged
Conversation
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
force-pushed
the
feat/nano-st1-receipts
branch
from
August 19, 2026 20:52
831f997 to
876d605
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug this started from
Backtester.verify_replayadvertised "bit-identical replay… byte-for-byte identical reports" and compared two Python dicts:{"approved": True} == {"approved": 1} # True {"x": 0.0} == {"x": -0.0} # TrueA host
DecisionGatebacked by JSON, a database, or numpy — one whose values change type between runs, which is the realistic case — passed as bit-identical.nano ... --verifyhad the same hole. Both now compare canonical bytes and name the drifted paths.Also found:
content_hashran withallow_nandefaulted true, so a non-finite float would be hashed over the non-JSON tokensNaN/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-dictmappings and reference cycles refused with a path.content_hash's own precedent that a document cannot commit to its own digest.verify_run/differences— drift is detected and located.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_digestacrossPYTHONHASHSEED∈ {0, 1, 987654321, 4294967295} × {3.11.9, 3.13.14} × permuted source-level dict construction order — compared withhashlib, not==.Float conventions are documented and executable:
-0.0,0.1+0.2,1e308,5e-324,1e16, integral floats,NaN/±Infrefusal. 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-cnot installed, and there is a test for that.Behavior change
verify_replaynow raisesReceiptError— aValueError, not the documentedReplayDivergence— for a host whose gate returnsnumpy.bool_ordecimal.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 ReplayDivergencewill not catch aReceiptError", 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_EFFECTSandEFFECT_ORDERunmoved. stdlib only.ReplayDivergencemoved tonano.runtime.receiptand is re-exported, soexcept nano.bridge.ReplayDivergencestill 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
main.capsysdoes no newline translation) — the guard now runs the CLI in a real subprocess with notext=True. It also found a regression a fix round introduced, where the deep-copy ofhostran before validation and swallowed the pathed error.nano/watchdogpackage it has never seen.Merge order
Rebased from
811d6c7onto currentmain. 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__.pyandnano/cli/commands.py— upstream's widened interpreter import (SignalFrame) kept alongside the receipt exports. The rebase also surfaced thattests/test_cli.pyand upstream'stests/test_watchdog.pyboth hardcode the package version and break on any bump; both now derive fromnano.__version__.🤖 Generated with Claude Code