This memo summarises the CTFS audit performed against
codetracer-wasm-recorder in iteration 1.60 of the IsoNim migration
campaign. It documents the architecture, the audit checklist outcomes,
the concrete fixes that landed in the same session, and the open
follow-ups that are out of scope for a single recorder audit.
For the broader campaign context, see
/tmp/isonim-migration.txt mission goals #5 (recorder fixes) and #6
(CTFS format migration), and the cross-cutting checklist in
section 5.6 of that file.
The recorder is a fork of wazero (a pure-Go WebAssembly runtime by Tetrate) extended with:
- A
tracewriterpackage exposing aTraceRecorderinterface used by the wasm interpreter to emit canonical CodeTracer events (RegisterCall/RegisterStep/RegisterReturn/RegisterVariable/RegisterRecordEvent). - A pure-Go writer (
go_writer.gowrappinggithub.com/metacraft-labs/trace_record) that emits the legacy three-file JSON layout (trace.json+trace_metadata.json+trace_paths.json). - A Rust-FFI writer (
rust_writer.golinking againstcodetracer_trace_writer_ffi) that buffers events in Go and replays them through cgo. - A Stylus-trace replay layer (
internal/stylus/) that hosts thevm_hookshost module Arbitrum Stylus contracts import, fed by an external EVMdebug_traceTransactionJSON.
The wasm interpreter (internal/engine/interpreter/interpreter.go)
drives RegisterCall / RegisterStep / RegisterReturn from DWARF
function records and line records. Stylus host functions
(internal/stylus/stylus_funcs.go) route the 32+ EVM hooks (emit_log,
call_contract, storage_load_bytes32, account_balance, ...) through
RegisterRecordEvent(EventKindEvmEvent, hookName, payload) -- the same
EvmEvent routing closed for the EVM recorder in iteration 1.39.
This is the second audited recorder that combines a Go process (wazero) with the Rust trace writer via cgo FFI -- the first being the PHP recorder (Zend extension via C FFI, audited 2026-05-02 in 1.41).
| Check | Item | Status |
|---|---|---|
| (a) | CTFS format compliance | GAP -- rust_writer.go hardcoded C.FMT_JSON; the Go writer emits its own legacy three-file JSON. |
| (b) | register_call for each call |
OK -- DWARF-driven RegisterCall at every wasm function entry plus inline-entry frames. |
| (c) | register_call_arg via writer.arg |
PARTIAL -- the Go side stages args via m.Record.Arg(name, val) correctly, but the FFI replay path in rust_writer.go::rustEventCall routes them through trace_writer_register_variable_* instead of a dedicated register_call_arg, so they surface as scoped variables rather than CallRecord.args. The C FFI does not yet expose trace_writer_register_call_arg -- same FFI-extension blocker as PHP 1.41. |
| (d) | Write / EvmEvent / Error routing |
PARTIAL -- EvmEvent routing is correct in stylus_funcs.go (32+ Stylus host fns); no Error routing for trace mismatches or wasm trap panics; no Write routing because wazero's stdout/stderr streams already flow through the wasi_snapshot_preview1 filesystem layer that the Go writer intercepts on its own path. |
| (e) | register_thread_* |
N/A -- wasm core is single-threaded in the interpreter path; the wasm threads proposal is not plumbed. |
| (f) | Step records | OK -- DWARF-driven RegisterStep in interpreter.go. |
| (g) | CTFS schema match | GAP -- both writer paths produce legacy schemas. |
| (h) | Obsolete add_event stubs |
OK -- no add_event in source. |
| (i) | #[no_mangle] collisions |
OK -- this is a Go binary calling Rust via cgo; no Rust-side #[no_mangle] stubs in the recorder. |
cmd/wazero/wazero.go: the wazero run subcommand gains a
-format flag (default ctfs) accepting ctfs / binary /
binary_v0 / json / go. A new resolveTraceFormat helper centralises
the per-format dispatch and the FFI-not-yet-exposed-Ctfs error message.
This mirrors the per-recorder default-Ctfs CLI idiom landed for Leo (1.59), Circom (1.58), TON (1.57), Miden (1.56), PolkaVM (1.55), Fuel (1.53), Flow (1.52), Cairo (1.50), Cardano (1.48), Move (1.46), Solana (1.44).
The Ctfs branch currently exits with a descriptive error pointing at
the FFI-extension follow-up shared with the PHP recorder. This makes
the future migration a one-line change in resolveTraceFormat once the
FFI exposes FMT_CTFS -- and crucially, today's default behaviour
does not silently produce a legacy format: the user has to opt in to
-format=binary or -format=go to record anything.
The legacy -use-rust-writer boolean is preserved for backwards
compatibility but is now incompatible with -format=go.
tracewriter/rust_writer.go: replaces the hardcoded C.FMT_JSON
with a configurable RustFormat field plumbed through a new
NewRustTraceWriterWithFormat constructor. The pre-existing
NewRustTraceWriter defaults to RustFormatJSON to preserve the
behaviour of any external caller; the new constructor lets cmd/wazero
request RustFormatBinary (CBOR+Zstd, the closest-to-modern variant
the FFI exposes today) via -format=binary.
A public RustFormat typed enum is exported with values mirroring the
FFI header (FMT_JSON=0, FMT_BINARY_V0=1, FMT_BINARY=2). When the
FFI gains FMT_CTFS we add a new constant and a new branch in
resolveTraceFormat; nothing else needs to change.
tracewriter/rust_writer_stub.go (the non-cgo build) re-exports the
same constants so cmd/wazero can compile without conditional source
code at the call site.
internal/stylus/stylus_funcs.go::exportFunc: pre-fix, every Stylus
host hook panicked unconditionally on a trace-mismatch
(trace.nextEvent(name) failure) or on downstream wasm-memory
panics. Post-fix, both failure paths route through
record.RegisterRecordEvent(EventKindError, "stylus_trace_mismatch" | "stylus_host_panic", msg) before re-raising the panic, so the partial
.ct container retains a breadcrumb at the failure point. Mirrors the
EVM 1.39 / Cairo 1.50 / PolkaVM 1.55 / Miden 1.56 / TON 1.57
Error-routing pattern.
The recorder is wired through a new StylusTrace.errorRecord field
(populated by exportSylusFunctions from the recorder it receives as
an argument) so the existing 34 exportFunc call sites do not change
shape.
tracewriter/rust_writer_test.go: adds three tests (one merged into
existing harness):
TestRustTraceWriterFormatConstantspinsRustFormatJSON=0,RustFormatBinaryV0=1,RustFormatBinary=2so a future re-cbindgen ofcodetracer_trace_writer.hcannot silently reorder the variants.TestRustTraceWriterWithFormatBinarysmoke-tests the new explicit-format constructor end-to-end (event recording +ProduceTraceagainst a temp dir).
direnv exec . go build ./cmd/wazero/ # clean
direnv exec . go test ./tracewriter/... # 4 pass (3 pre-existing, 2 new under TestRustTraceWriter*, +1 const pin)
direnv exec . go test ./internal/stylus/... # no test files
direnv exec . go test -count=1 ./cmd/wazero/... # pass (~0.4s)
No regressions; no linter touches required.
These are out of scope for a single recorder audit and are tracked either at the FFI extension layer or in this recorder's future iterations. Each follow-up describes the fix shape so the next sub-agent can pick it up without re-deriving the analysis.
Same blocker as PHP recorder 1.41
(see codetracer-php-recorder/AUDIT-CTFS-2026-05.md "Open gaps"
section). Fix shape (in codetracer-trace-format/codetracer-trace-writer-ffi):
- Add a
Ctfsvariant to theFmtenum, dispatching to the multi-stream.ctwriter. - Add a
trace_writer_register_call_arg(handle, name, value, ...)entry point so FFI consumers can stage args onCallRecord.argsinstead of as scoped variables. - Add
trace_writer_register_thread_{start,exit,switch}entry points. - Re-run
cbindgento regenerate the headertracewriter/codetracer_trace_writer.h; bump the#defineguard / version comment so consumers can detect the new ABI. - Add a corresponding
RustFormatCtfs RustFormat = 3constant inrust_writer.go(andrust_writer_stub.go); flip thecase "ctfs"branch incmd/wazero/wazero.go::resolveTraceFormatfrom the error path toreturn tracewriter.RustFormatCtfs, formatKindFFI, nil.
After this lands, the audit's (a) / (c) / (g) gaps close with no recorder-side code change beyond the constant + dispatch flip.
Audit (c) on the source-level path is already closed via
m.Record.Arg(name, val) in
interpreter.go::traceFunctionEntry -- live values flow through
DWARF FunctionRecord.Params + readVariable. The downstream FFI
replay path collapses them onto register_variable_* because the FFI
lacks register_call_arg (follow-up A). Once A lands, no recorder
work is needed.
For wasm modules without DWARF (raw .wasm files compiled without
-g), the recorder cannot recover parameter names; placeholder
staging via argN per local would mirror the Miden 1.56 operand-stack
pattern (stack[0..3] -> s0..s3). Out of scope; documented for
completeness.
stylus_funcs.go::exportEmitLog writes the raw hex bytes of the
emit_log payload to the EvmEvent content. A future iteration can
parse the EVM ABI (topics + data) into a structured payload so the
GUI's events panel renders human-readable args (mirrors EVM 1.39's
"convert this to human readable format" TODO that is still open). Out
of scope for the CTFS audit.
The wazero engine does not currently support the wasm threads
proposal. If a future iteration enables it, the recorder must call
register_thread_start / register_thread_exit /
register_thread_switch (FFI extension follow-up A above) at the
per-thread entry / exit points. Currently flagged N/A for this audit.
Cross-cutting issue documented in 1.39 / 1.41 / 1.44 / 1.46 / 1.48 /
1.50 / 1.52 / 1.53 / 1.55 / 1.56 / 1.57 / 1.58 / 1.59. Once the
wasm recorder routes EvmEvent and Error through the multi-stream
writer, both currently collapse onto stdout/stderr buckets and lose
the metadata field. Out of scope for any single recorder audit;
flagged as a writer-side fix.
The new TestRustTraceWriterWithFormatBinary smoke-tests that
ProduceTrace runs without error. It does not walk the resulting
file and assert specific event records.
This is now explicitly blocked by follow-up A for the wasm/wazero
recorder. The currently supported Rust FFI formats are JSON,
BinaryV0, and Binary; none of them produce the canonical multi-stream
CTFS .ct container consumed by NimTraceReaderHandle. The available
binary path is the legacy CBOR+Zstd format produced through the same
three-phase trace.json / trace_metadata.json / trace_paths.json
FFI calls, so adding a Nim-reader test today would only prove that the
reader cannot open the expected format.
Once follow-up A adds FMT_CTFS, the minimal recorder-side test shape
is:
- Add
RustFormatCtfsinrust_writer.go/rust_writer_stub.goand route-format=ctfsthroughNewRustTraceWriterWithFormat. - Extend
tracewriter/rust_writer_test.go(or add a nearby cgo-only test) to record a tiny function with one step, one call, one staged argument, and one return usingRustFormatCtfs. - Open the produced
.ctcontainer through the Nim CTFS reader and assert concrete content: function name, source path/line, the step, andCall.args[]. - Add a Stylus-host mismatch/panic fixture only if the FFI test already
proves the basic call/step/return stream is reader-visible; then
assert the
Errorspecial event content.
-
Two-process Go+Rust recorders. The wasm recorder is the second audited cgo-based recorder (after PHP 1.41) where the Rust FFI surface is the binding constraint -- not the recorder logic. The PHP recorder's "FFI extension" follow-up (see
codetracer-php-recorder/AUDIT-CTFS-2026-05.md) now blocks the wasm audit's (a) / (c) / (g) gaps too. Bundling these two recorders behind a single FFI-extension PR is the highest-leverage next step. -
-formatCLI default for binaries that wrap a runtime. Unlike the Rust crates (Leo / Miden / TON / ...) where the recorder is the CLI, wazero is a long-lived runtime CLI with many existing flags and call sites. Defaulting-format=ctfshere required designing the error path to be informative-but-blocking rather than silently falling back, so users get a clear pointer at the FFI follow-up. Pattern is reusable for any future cgo recorder. -
Stylus host-fn panic routing. The 34-call-site
exportFuncpattern is identical to the waywazero/internal/wasi_snapshot_preview1exports WASI functions. If the WASI host-fn layer ever needs Error routing for I/O failures, the sameerrorRecordfield idiom on the module-builder factory is reusable.
Audit performed by Claude Opus 4.7 (1M context) on 2026-05-02 as part
of iteration 1.60 of the IsoNim migration campaign. See
/tmp/isonim-migration.txt for the full campaign log.
Iteration 1.60 left the -format CLI flag in place to allow a graceful
opt-in to binary / json / go writers while the FFI exposed neither
FMT_CTFS nor a Nim-backed CTFS variant. The cross-cutting recorder
convention pass (Python, Ruby, JavaScript, Cairo, Cardano, Circom, Flow,
Fuel, Leo, Miden, Move, PolkaVM, Solana, TON, Bash, Zsh, EVM, Native) has
since standardised the recorder CLI surface on CTFS-only output with
no --format flag (Recorder-CLI-Conventions.md §4) and on
per-recorder env-var fallbacks (§5). This follow-up brings the wasm
recorder onto that contract.
Recorder-CLI-Conventions.md §1 documents the wasm recorder as the
one exception to the codetracer-<lang>-recorder binary-name rule:
the recorder is a fork of Tetrate's wazero
runtime with tracing layered in, not a CodeTracer-named tool. The binary
keeps the upstream wazero name; the convention's other contracts
(--out-dir, env vars, no --format, ct print mention) apply
unchanged. See Recorder-CLI-Conventions.md Implementation Status table
for the canonical phrasing ("✓ Compliant (CTFS-only, binary name
exception)").
-
-formatflag removed (cmd/wazero/wazero.go). TheresolveTraceFormathelper, theformatKindenum, and the-use-rust-writerboolean all went with it. Today the recorder resolves--out-dirplus the new env-var fallback into a single pinnedtracewriter.GoWriterinstance. When the FFI/Nim CTFS writer lands the dispatch switches to it without changing the CLI surface. -
CODETRACER_WASM_RECORDER_OUT_DIRis now read as a fallback for--out-dir(cmd/wazero/wazero.go,doRun). Mirrors §5 across the recorder fleet. -
CODETRACER_WASM_RECORDER_DISABLEDshort-circuits recording entirely. When set, the recorder runs the target through wazero without instantiating aTraceRecorder; no trace artefacts are written. Mirrors §5 across the recorder fleet. -
Rust FFI writer removed (
tracewriter/rust_writer.go,rust_writer_stub.go,rust_writer_test.go,tracewriter/codetracer_trace_writer.h). The FFI writer was the only consumer of the--formatflag and produced legacy non-CTFS shapes. It can be re-added if and when the FFI exposesFMT_CTFS(open follow-up A above), without touching call sites. -
Help-text updates —
wazero -handwazero run -hnow mention the env vars andct printfromcodetracer-trace-format-nimas the canonical conversion tool. The legacy--formatflag is no longer advertised; theflagpackage rejects it with the standard "flag provided but not defined" error. -
Tests (
cmd/wazero/wazero_test.go):TestNoFormatFlagInHelp— sweeps top-level /run/compile-hto ensure no--format/CODETRACER_FORMATadvertisement.TestHelpMentionsCtPrint— both top-level andrun -hmust mentionct print.TestFormatFlagRejected—wazero run --format json fixture.wasmmust exit non-zero (re-execs the test binary as a subprocess becauseflag.ExitOnErrorcallsos.Exit(2)on the unknown flag).TestEnvOutDirUsedWhenFlagOmitted— exercises theCODETRACER_WASM_RECORDER_OUT_DIRfallback, asserting the three-file JSON layout lands in the env-supplied dir.TestEnvDisabledSkipsRecording— exercises theCODETRACER_WASM_RECORDER_DISABLED=1short-circuit, asserting no trace artefacts are written.TestRecordedTraceViaCtPrintJson— records a tiny WASI fixture and pipes the resulting bundle throughct-print --json, asserting on structural anchors (metadata/paths/functions/steps/calls/ioEventssection names) rather than on integer values that don't round-trip today. Skips gracefully when ct-print is not present (i.e. when this repo is built outside the metacraft workspace). Mirrors the pattern landed for cardano / circom / flow / fuel / leo / miden / move / polkavm / python / ruby / solana / ton.
-
tests/verify-cli-convention-no-silent-skip.sh— shell guard that asserts:--format/CODETRACER_FORMATabsent fromwazero -h,wazero run -h,wazero compile -h.--out-dir,ct print, both env-var names present inwazero run -h.CODETRACER_WASM_RECORDER_OUT_DIR/CODETRACER_WASM_RECORDER_DISABLEDreferenced incmd/.tracewriter/rust_writer.gono longer exists (catches a partial revert). Wired into theJustfile(just verify-cli-convention,just check-all).
-
README.md— updated to advertise the env-var fallback, the binary-name exception, and thect printconversion workflow; the "Building with the Rust FFI writer" section was replaced with a brief workspace-layout note. -
Recorder-CLI-Conventions.mdImplementation Status row flipped from⚠ Partialto✓ Compliant (CTFS-only, binary name exception).
direnv exec . go build ./cmd/wazero/ # clean
direnv exec . go test -count=1 ./cmd/wazero/ # all CLI tests, including the
# new no-silent-skip set
direnv exec . go test -count=1 ./tracewriter/ # CTFS writer test set
direnv exec . bash tests/verify-cli-convention-no-silent-skip.sh # all assertions hold
Open follow-up A (FFI extension exposing FMT_CTFS /
register_call_arg / register_thread_*) remains the path to a real
multi-stream .ct container; today the recorder's pinned Go writer
emits the legacy three-file JSON layout that ct-print also accepts. The
open follow-ups B–F from the 2026-05-02 audit are unchanged.
The deferred follow-up A from above closed in the same iteration as the convention compliance pass:
tracewriter.GoWriter(legacy three-file JSON) was replaced withtracewriter.CtfsTraceWriter, a cgo binding tocodetracer-trace-format-nim/src/codetracer_trace_writer_ffi.nim. The Nim FFI mapsFFI_TRACE_FORMAT_BINARY(= 2) onto its multi-streamMultiStreamTraceWriter, producing a single<program-basename>.ct(CTFS) container under--out-dir— no legacy fallback files are emitted.- Header
tracewriter/codetracer_trace_writer.his the verbatim copy ofcodetracer-trace-format-nim/include/codetracer_trace_writer.h; the cgo binding links statically againstlibcodetracer_trace_writer.aplus-lzstd -lm -lpthread. - Typed value variants surface correctly:
IntValueRecord→kind: "Int"(viatrace_writer_register_variable_int/trace_writer_register_return_int),StringValueRecord→kind: "String"(via the streaming CBOR encoderct_value_write_string+trace_writer_register_variable_cbor),StructValueRecord→kind: "Struct"(viact_value_begin_struct+ct_value_end_compound). The deferred Skipf inTestRecordedTraceViaCtPrintJsonwas retired and the strict exact-value assertions now run unconditionally. - The
nix developshell hookscripts/detect-trace-format.shwas rewritten to find thecodetracer-trace-format-nimsibling, buildlibcodetracer_trace_writer.avianimble buildLibif missing, and exportCGO_CFLAGS/CGO_LDFLAGSplusLD_LIBRARY_PATHfor both the Nim FFI andlibzstd.wazero.nixaccepts acodetracer-trace-format-nimderivation (replacing the previouscodetracer-trace-writer-ffiinput) for production builds.
Open follow-ups B–F from the 2026-05-02 audit are unchanged. The
register_call_arg / register_thread_* half of follow-up A is now
exposed by the Nim FFI but the wazero recorder does not yet emit
arguments live (they still surface as scoped variables) — that's a
recorder-side improvement tracked alongside follow-up B.