llvm-dsdl — Deep Architectural Review & Production-Readiness Report Card¶
Generated 2026-06-28 against commit
c18867a. Source references are written aspath:linefor the tree at review time; line numbers will drift as the code evolves. This document is intended to be committed and iterated on — update verdicts and check off roadmap items as the gaps close.Update 2026-07-03 — P0 "Truthful assurance docs" + "Behavioural gates" landed. Work done in this pass, plus corrections to this report where the original review was itself stale or overstated (verified against the current tree): - CI is committed (
.github/workflows/ci.yml,coverage.yml,docs.yml) and the report gates already hard-fail the build (message(FATAL_ERROR …)in thecmake/Run*Report.cmakewrappers, run by therelease-blocking-report-gatestarget). G8 / rec 9's "CI uncommitted" and G4/G5's "gates are theater" premises were stale. - Behavioural gates: the parity, malformed-input, and determinism scorecards now consume executed ctest pass/fail (JUnit), notctest -Ntest-name presence — a cell iscoveredonly if a matching test ran and passed (fail/skip/absent ⇒ uncovered). The convergence scorecard is relabeled as an infrastructure-consistency lint (it is inherently a marker check and cannot be made behavioural cheaply). See §5 P0. - Passes renamed/redescribed:dsdl-prove-zero-overhead→dsdl-annotate-aliasability(drops "proof" language; it is a conservative annotator);dsdl-legalize-endiannessdocumented as validation-only (no byte reordering). - Big-endian is NOT "unimplemented". The original G5/G6/§3 "big-endian absent / needs byte-swap" claim is overstated — see the corrected notes below. DSDL wire is always little-endian and the field-wiseserialize_/deserialize_are host-endianness-agnostic. A host-image type's folded bodies (P1, phase 3) are little-endian only: they are generated only for a little-endian target triple, and the generated code refuses a big-endian build.Update 2026-09-17 — G6's aliasability strand re-audited, verdict corrected. Measured against
49b8883by generating and running code from the embedded catalogue. - The 2026-07-03 note that the flag "flows out only as a generated boolean constant" is stale.ZOH_ALIAS_ELIGIBLEalso gatestry_deserialize_view_/try_serialize_view_in C, C++ and Rust, present since71dbebe(2026-02-25).serialize_/deserialize_are untouched, so the sentence is defensible, but it reads as "the flag is inert" and it is not. - The criticism was too soft.dsdl-annotate-aliasabilitydecides a wire-layout property; the API it gates needs a host-memory-layout property; nothing connects the two. The flag is wrong for 9 of the 63 eligible catalogue types on arm64. See G6 in §2 and the P1 entry. -try_deserialize_view_returns the pointer it was given after a bounds check, andtry_serialize_view_memcpys the whole payload. Neither is called by any test, and the API appears in no page underdocs/reference/.
Scope reviewed: ~45k LOC C++ (lib/, include/, tools/), ~3.6k LOC multi-language runtime, ~17k LOC tests, the MLIR dsdl dialect, 6 codegen backends, the LSP, and the build/CI/convergence tooling. Method: docs read first (DESIGN.md, the design pages under docs/, the four matrix docs), then implementation, then adversarial verification of every headline claim against the actual code. Version under review: 0.1.0.
1. Verdict¶
This is an advanced prototype. The MLIR investment is real, the frontend is hardened, the runtime read-path inherits its bounds-safety, and differential testing is established practice.
But there is a consistent, structural problem that dominates the production-readiness assessment:
The assurance narrative substantially overstates what the implementation proves. "Convergence = 100," "zero-overhead proof," "producer/consumer drift detection," "verifier-first invariant enforcement," "fallback-free," and "release-blocking malformed/parity gates" are presented as guarantees. On inspection, the three headline scorecards are marker-presence / test-name-presence metrics, two of the headline passes are annotators/validators with grandiose names, and the strongest assurance claim (differential parity against the reference compiler) covers 5 hand-picked types and does not run in CI.
The engineering substance grades around a B−; the assurance claims grade around a C−. Closing that gap — partly by building the verification the docs already promise, partly by relabeling what can't be — is the central task of going from prototype to high-assurance. No agent found a critical defect, but the aggregate is 0 critical / 23 high / 40 medium / 23 low, and several "high"s are real safety gaps (unbounded LSP allocation, no sanitizer/native-fuzz coverage of generated decoders, unenforced primitive bit-widths).
Claim audit tally across the review: 28 holds · 32 partial · 5 overstated · 2 false · 9 unverifiable.
2. Efficacy Against Stated Design Goals¶
| # | Stated goal | Verdict | Grade |
|---|---|---|---|
| G1 | "Shared semantics, multiple syntaxes" | Partial → holds (2026-09-11) — every backend's bodies are translations of one plan-body IR; spellings carry surface idiom only | C+→A− |
| G2 | LLVM/MLIR as real infrastructure | Holds — operational | A− |
| G3 | Contract boundaries / drift detection | Overstated — presence/identity guard, not drift detection | C |
| G4 | Backend parity / convergence = 100 | Overstated → addressed (2026-07-03) — parity/malformed/determinism now behavioural (executed pass/fail); convergence relabeled as a lint | C−→B |
| G5 | Malformed safety + determinism + release-blocking gates | Partial → improved (2026-07-03) — real read-path safety; gates now behavioural; big-endian present (not "absent") | C |
| G6 | Zero-overhead proof / verifier-first / fallback-free | Overstated; re-audited 2026-09-17 — verifier-first closed; aliasability asserts a memory-layout property it never checks (wrong for 9 of 63 eligible catalogue types); fallback-free still a lint | C− |
| G7 | DSDL v1.0 spec conformance | Partial — grammar strong; primitive widths unenforced; parity narrow | C+ |
| G8 | Reproducible/deterministic builds | Partial → holds (2026-08-20) — per-backend determinism lanes; hashed-iteration lint; catalogue integrity gated | B−→B+ |
G1 — "Shared semantics, multiple syntaxes" → Partial (shared planning, per-backend rendering)¶
This is the load-bearing claim, and the truth is in the middle. A substantial shared layer exists: lib/CodeGen/MlirLoweredFacts.cpp, lib/CodeGen/RuntimeLoweredPlan.cpp, LoweredRenderIR, NativeHelperContract/HelperSymbolResolver, and one traversal (lib/CodeGen/NativeEmitterTraversal.cpp) drive field ordering, union dispatch, and helper-binding requirements uniformly. The C backend delegates to MLIR (convert-dsdl-to-emitc + EmitC), reimplementing nothing.
But the actual control-flow emission is hand-written per backend. emitSerializeUnion is independently coded in lib/CodeGen/emitter/Rust.cpp:559 and lib/CodeGen/emitter/Go.cpp:585 — same intended behaviour, six separate hand-written renderings. A bug like "mask-before-validate vs validate-before-mask" in one backend would not be caught by the convergence machinery. So G1 is aspirationally true and structurally partial: the semantics are planned once and rendered six times, with cross-language agreement enforced by tests rather than by construction.
Update 2026-09-11 — addressed. Every backend's serialise and deserialise bodies are translations of the
build-dsdl-plan-bodiesIR through one translator (lib/CodeGen/BodyTranslator.cpp), so the order is correct by construction; a spelling per language carries surface idiom only.ctest -L backend-contractperturbs the bodies and fails a backend whose output does not follow. Seedocs/development/backend-translation.md.
G2 — MLIR as real infrastructure → Holds¶
This is the project's strongest claim and it is true. There is a proper ODS dialect (include/llvmdsdl/IR/DSDLOps.td, DSDLTypes.td, DSDLAttrs.td), ops with real verifiers (SerializationPlanOp::verify(), IOOp::verify() reject malformed union/array/cast metadata), a working pass pipeline, and an EmitC lowering path producing C. MLIR is operational infrastructure here, not scaffolding. (Minor: dsdl.field/dsdl.constant ops appear to be dead — defined but unconsumed — and should be removed or documented.)
G3 — Contract boundaries / drift detection → Overstated¶
The contract mechanism is real and enforced at the consumer (codegen aborts on a missing/unsupported contract, with negative tests). But it is not drift detection. Producer stamp and consumer check both read the same constant kLoweredSerDesContractMajor = 2 (include/llvmdsdl/Transforms/LoweredSerDesContract.h:28); within a build they cannot disagree. lib/Transforms/LoweredSerDesContractValidation.cpp:46 checks version-equality and producer-string-equality. So it detects "the lowering pass didn't run / raw IR was fed to a backend" — a useful guard — but the docs' "detects producer/consumer drift early" implies semantic-compatibility checking that doesn't exist (no field-level compatibility rules, no payload divergence detection).
G4 — Convergence / parity = 100 → Overstated (the number measures markers, not behaviour)¶
Verified in the source directly. tools/convergence/convergence_report.py:180-282 computes the "14/14 shared" score by re.search-ing each emitter's .cpp for call-string markers like collectLoweredFactsFromMlir(, renderSectionHelperBindings(, unionTagValidate. A backend scores 100 by mentioning the shared helpers; it would still score 100 after weakening a validation step, as long as the marker strings remain. The same pattern holds for the parity scorecard (tools/convergence/parity_matrix_report.py: a cell is "covered" if a ctest -N test name matches a regex — it never runs the harness) and the malformed scorecard (tools/convergence/malformed_contract_matrix_report.py:237: same ctest -N name-presence).
Real behavioural testing does exist and is good — e.g. C↔Go generated, compiled, CGO-linked, round-tripped over 128 random + 265 directed cases with hard-fail on byte mismatch. The problem is purely that the published "100" scores certify label presence, not the behaviour the docs imply. They should be relabeled as infrastructure-consistency lints.
G5 — Malformed safety + determinism + release-blocking gates → Partial¶
The generated C read path is bounds-safe: it inherits the Nunavut/libcanard copy_bits primitive that clamps each read window to the buffer via saturate_fragment_bits/choose_min (runtime/dsdl_runtime.h:98+), and variable-array decode validates the length prefix before the element loop (no OOB writes from inflated counts).
Weak where it matters most — ~~(a) there is no ASan/UBSan/MSan anywhere~~ and ~~(b) only the Python runtime … is fuzzed~~ (both addressed 2026-07-03: the ci-asan preset + sanitizers CI lane run ASan/UBSan over the generated C/C++/Go decoders and a coverage-guided libFuzzer lane over the native C deserialisers on the real corpus — see P0); (c) ~~the "release-blocking malformed gate" is the name-presence metric above~~ (fixed 2026-07-03: the malformed/parity/determinism gates now consume executed ctest pass/fail — behavioural, not name-presence); (d) the release copy_bits path uses assert() guards that vanish under NDEBUG. For an avionics-adjacent decoder of untrusted bytes, this is the single most important gap.
G6 — Zero-overhead proof / verifier-first / fallback-free → Overstated¶
The three strands have moved apart. Re-audited 2026-09-17 against 49b8883; the aliasability
finding below is measured, not read.
- Aliasability — the flag names a guarantee it does not hold.
dsdl-annotate-aliasability(renamed 2026-07-03 fromdsdl-prove-zero-overhead;lib/Transforms/Passes.cpp:1203) decides a property of the wire layout: fixed, sealed, byte-aligned, byte-multiple, no variable arrays, no composites. It stampszoh_alias_eligible, which reaches the generated code asZOH_ALIAS_ELIGIBLEin all six languages and, in C, C++ and Rust, gatestry_deserialize_view_/try_serialize_view_. Those functions are only useful if the host struct is a byte image of that wire layout, and nothing checks that: it is an ABI property of the user's compiler, whichdsdlccannot observe when it emits a header. Measured over the embedded catalogue, 9 of the 63 eligible types have a struct that is not a byte image of their own wire form on arm64 —uavcan.time.SynchronizedTimestampis 8 bytes of struct against 7 of wire (uint56in auint64_t),uavcan.primitive.scalar.Real16is 4 against 2 (float16in afloat),uavcan.metatransport.udp.Endpointis 24 against 32 (wire padding the struct omits). The Rust struct carries norepr, so its field order is unspecified by the language. See P1. - "verifier-first" ✅ closed (2026-07-12):
lowerToMLIRcallsmlir::verify()on its own output under a scoped diagnostic handler (lib/Lowering/LowerToMLIR.cpp:447), so every backend consumes verified IR. See the P2 entry. - "fallback-free" is scored by searching generated text for marker substrings
(
test/integration/RunUavcanGeneration.cmake).
G7 — DSDL v1.0 conformance → Partial¶
Strong: the lexer/parser went through 6 documented rounds of grammar conformance and reserved-identifier enforcement; all six directives (@union/@sealed/@extent/@assert/@print/@deprecated) are parsed and semantically checked; delimited (non-sealed) bit-length sets are modeled correctly (32-bit delimiter header + 0..extent), matching pydsdl/Nunavut.
Real conformance bug found and verified live ~~primitive bit-width constraints are not enforced. uint100 and float8 pass parse + semantic analysis and emit structurally-valid MLIR~~ (fixed 2026-07-10: the frontend parser and the dsdl.io verifier now reject out-of-range primitive widths — int/uint [1,64], float {16,32,64}, void [1,64] — with pydsdl-style diagnostics; see P0). (Originally: lib/Frontend/Parser.cpp:725 only checked uint32 fit; no range check in lib/Semantics/Analyzer.cpp or the scalar IR verifier.) The reference compiler rejects these with diagnostics. And the differential parity vs Nunavut covers exactly 5 files (test/integration/RunDifferentialParity.cmake:99), disables byte comparison for the union and float cases, and is skipped in CI (the workflow never provisions nunavut/pydsdl).
G8 — Reproducible builds → Holds¶
Good preset/workflow discipline and depfile support. Seven per-backend determinism lanes compare
two runs of the same input, llvmdsdl-determinism-unordered-iteration structurally excludes the
hashed-iteration divergence those lanes cannot see, and the embedded catalogue is gated on its digest.
Short of an A because the strongest form of the guarantee is not exercised: the cross-architecture gate builds both halves with one toolchain, so a divergence that only appears across standard library implementations rests on the lint rather than on a differential build.
~~The 545 KB embedded UAVCAN MLIR catalogue (lib/CodeGen/UavcanEmbeddedMlir.inc) ships a declared SHA-256 that is never validated at runtime and is regenerated by a standalone script CMake never invokes — so a stale or corrupt catalogue ships silently.~~ (fixed: loadUavcanEmbeddedCatalog gates on the recorded digest before parsing and refuses a mismatch, with unit coverage for the accept and reject paths; llvmdsdl-embedded-uavcan-catalog-guard runs the generator with --check, which regenerates and compares byte-for-byte, and a selftest covers the guard. The catalogue is 531 KB.)
~~And CI itself is uncommitted (ci.yml, coverage.yml, .github/actions/ are untracked), so the "release-blocking" lanes aren't yet enforced by the repo.~~ (fixed: both workflows and both composite actions are tracked.)
~~unordered_map/unordered_set appear in 10 codegen files (iteration-order determinism risk, only partly covered by determinism tests).~~ (fixed: the file count was never the risk — three of the ten only #include the header, and none of the declarations in the other seven are traversed. The three traversals that exist are in tools/dsdlc/main.cpp and each carries the // determinism-ok: annotation with its reason. llvmdsdl-determinism-unordered-iteration enforces that convention over the generated-output path, rejecting an unannotated traversal.)
3. Subsystem Scorecard (0 = prototype, 10 = high-assurance-ready)¶
| Subsystem | Score | One-line assessment |
|---|---|---|
| Frontend (lexer/parser/discovery) | 8 | Hardened: fuzzed, crash-fixed, grammar-conformant. Add recursion/element caps. |
| Codegen Rust/Go/TS/Python emitters | 8 | High-quality, idiomatic output; ~200–500 LOC duplicated control-flow. |
| IR dialect + lowering | 7.5 | Real ODS dialect + verifiers; dead ops; lowering lacks proactive verifiers. |
| Semantics | 7 | Solid BitLengthSet algebra; unchecked Rational int64 overflow, unbounded repeatRange expansion. |
| Runtime (multi-language) | 7 | Read-path bounds-safe; empty semantic-wrapper allowlist; no isolated cross-language primitive tests. |
| Codegen C/C++/Object | 6.5 | Clean C-via-EmitC; object backend exec is shell-safe; targetTriple input under-validated. |
| Transforms | 6 | Real contract enforcement; big-endian implemented — wire is LE, the field-wise serialize_/deserialize_ host-agnostic; a host image's folded bodies are little-endian only and refuse a big-endian build. dsdl-verify-alias-layout re-derives the wire-flat and host-image verdicts the analyser stamps, and dsdl-fold-host-image-bodies rewrites a host image's bodies to one move (2026-09-18; P1). |
| Codegen shared layer | 6 | Shared planning; semantics still re-rendered per backend. |
LSP (dsdld) |
6 | Reuses compiler core (good); ~~unbounded Content-Length allocation (OOM DoS)~~ capped + overflow-safe (2026-07-10); ~~DocumentStore thread-safety still open~~ verified thread-safe (2026-07-15): every method locks mutex_ and lookup returns a snapshot copy, so no reference escapes to a concurrent scheduler-worker request. |
| Tools / CLI | 6 | Good arg/exit discipline; embedded-catalog freshness & integrity ungated. |
| Build / CI / test infra | 6→8 | 206 tests, 8 guard selftests, 15 release-blocking; gates consume executed results; seven CI workflows tracked. A gate that skips half of itself for a missing toolchain still reports a pass. |
| Headline-claim integrity | 5 | The dominant production risk: docs promise proofs the code doesn't deliver. |
4. Top Recommendations (by leverage)¶
Highest leverage — make the claims true or relabel them:
- Re-found the three scorecards on behaviour, not markers. ✅ Done (2026-07-03) for parity/malformed/determinism — they consume
ctestpass/fail (JUnit) viatools/convergence/ctest_results.py; a cell is covered only if a matching test ran and passed. Convergence relabeled as an infrastructure-consistency lint (docs/development/convergence-scorecard.md) rather than made behavioural (it is inherently a marker check). Deriving convergence from generated-output/AST equivalence remains a worthwhile P1 deepening. - ⚠️ Reopened 2026-09-17 (was ✅ Done 2026-07-03). Renamed
dsdl-prove-zero-overhead→dsdl-annotate-aliasabilityand dropped "proof" language; documenteddsdl-legalize-endiannessas validation-only (no byte reordering). ~~mark--target-endianness bigEXPERIMENTAL/unsupported until byte-swap logic exists~~ — withdrawn as overstated: DSDL wire is always little-endian, so there is no byte-swap to implement;serialize_/deserialize_are host-endianness-agnostic and byte-parity-tested against little-endian in the-l objsmoke test. Only the zero-copy view fast-path is disabled on BE (returns an error), which is correct, not missing. The rename settled the pass's name, not its meaning — the verdict it stamps is about the wire and the API it gates is about host memory. Tracked under P1. - Build the verification the docs already promise: ✅ Nunavut differential parity now runs in CI (2026-07-10) — provisioned + loudly required; byte comparison always-on (non-float byte-exact, float byte-exact except NaN payloads); coverage broadened 6 → 10 cases incl. a byte-exact non-float union, fixed+variable arrays, nested composites, and a narrow scalar. Reframed and closed (2026-07-12): Nunavut is a pinned peer implementation used for corroboration, not the oracle — the Cyphal Specification is the truth and
spec/dafny/CyphalSerdes.dfyis the machine-checked oracle. Further coverage expansion is retired by decision; see the P0 entry for the authority hierarchy and rationale.
Safety / high-assurance:
- ✅ Done (2026-07-03). Added the
sanitizersCI lane (linux/toolshed, Clang): ASan+UBSan over the generated native C/C++/Go decoders (via the parity harnesses recompiled instrumented) and a coverage-guided libFuzzer lane over the generated C deserialisers on the real UAVCAN corpus, covering the nested-delimited / unions-of-composites / variable-array shapes. See the P0 entry below for the components. (Remaining P1 deepening: expand the fuzzed type set beyond the curated 7 and enable byte-for-byte serialise re-check assertions inside the fuzz round-trip.) ✅ Extended to every runtime (2026-07-12): decoder fuzzing now spans all five languages. Each memory-safe runtime gets a dedicated decoder-fuzz lane matched to its failure class — Go (llvmdsdl-go-decoder-fuzz, Go-nativego test -fuzzover the adversarial UAVCAN set; a slice-bounds panic fails it; seed replay in CI,LLVMDSDL_GO_FUZZTIMEfor the deep run), Rust (llvmdsdl-rust-decoder-fuzz, deterministic byte stream through eachdeserializeundercatch_unwind; a panic fails it), and TS (llvmdsdl-ts-decoder-fuzz, tsc+node over a compact adversarial fixture set; aRangeError/TypeErrorescapingdeserialize, or a hang caught by the harness timeout, fails it) — alongside the existing C libFuzzer lane and the Python malformed-decode-fuzz. All accept-then-round-trip throughserialize. Result: arbitrary wire bytes into any generated decoder produce a decoded object or a clean rejection, never a panic / uncaught fault / hang — verified at 8M (Go), 300k (Rust), 100k (TS) executions with zero faults. This closes the G5 "only the Python runtime is fuzzed" gap. - ✅ Done (2026-07-10). Primitive bit-length constraints are now enforced in the frontend parser and the
dsdl.ioIR verifier, with per-kind diagnostics. Per the Cyphal Specification these are signed int [2,64] (not 1 —int1is invalid), unsigned int [1,64], float ∈ {16,32,64}, void [1,64]. See the P0 entry below. (This corrects the earlier "int/uint 1..64" shorthand — signed and unsigned have different minimums.) - Bound the LSP: ✅
Content-Lengthcap done (2026-07-10) — bounded + overflow-safe before allocation (lib/LSP/JsonRpcIO.cpp; see P0). Remaining: makeDocumentStoreaccess thread-safe (tracked under P1). - Harden semantics: checked/
__int128Rationalmultiply; boundBitLengthSetexpansion against adversarialrepeatRange; cap array capacity.
Integrity / reproducibility:
- Gate the embedded catalogue: validate its SHA-256 at runtime; wire generation +
--checkfreshness into CMake/CI so a stale submodule fails the build. - Commit CI and fix gate ordering (
if: always()lets failures slip); make toolchain-missing test skips loud (emit a coverage manifest) so local green ≠ false confidence. - ✅ Done (2026-07-11).
semantic_wrapper_allowlist.jsonis justified-empty (rationale documented in-file; the validator only tracks hand-written, non-generated above-primitive wrappers, of which there are none, and the release-blocking allowlist lane passes). Added the isolated cross-language primitive equivalence harness (llvmdsdl-primitive-equivalence) over C/Rust/Go/Python/TS, asserting each primitive direction independently (float16 pack/unpack, sign-extend, unsigned read,copy_bits) so paired bugs can't mask each other — which immediately caught and fixed a real Go+Rustfloat16_packbug. See the P1 entry.
5. Production-Readiness Report Card → work to reach high-assurance public release¶
Current standing: an advanced prototype (overall ≈ C+/B−). Strong bones; oversold guarantees; a handful of real safety gaps. The work below is the prototype→high-assurance path, in priority order.
P0 — Release-blocking (must close before any "high-assurance" claim)¶
- [x] Truthful assurance docs. (Done 2026-07-03.) Convergence relabeled as an infrastructure-consistency lint; parity/malformed carry explicit structural-vs-behavioural mode banners;
dsdl-prove-zero-overhead→dsdl-annotate-aliasability(drops "proof");dsdl-legalize-endiannessdocumented validation-only; big-endian docs corrected (docs/reference/codegen/object.md). Big-endian not marked unsupported — that premise was overstated (big-endian is implemented; see the 2026-07-03 update note). - [x] Behavioural gates. (Done 2026-07-03.) Parity/malformed/determinism scorecards consume executed ctest pass/fail via JUnit (
tools/convergence/ctest_results.py); a cell iscoveredonly if a matching test ran and passed (fail/skip/absent ⇒ uncovered). Gates hard-fail on regression (already did) and now on behavioural coverage loss. CI feeds the suite's JUnit intorelease-blocking-report-gatesvia theLLVMDSDL_REPORT_GATE_JUNITcache var; missing results fail loudly (no silent "no data = pass"). Red-team verified: flipping one parity test to fail breaks the gate. (Remaining nuance: the in-suite ctest coverage tests still run structurally as a fast pre-check; the authoritative behavioural gate is the post-suite target.) - [x] Sanitizers + native decoder fuzzing in CI. (Done 2026-07-03.) New
ci-asanpreset +sanitizersCI job (linux/toolshed, Clang-forced): ASan/UBSan over the generated C and C++ decoders via the existing parity harness recompiled instrumented (RunCppCParity.cmakegains aSANITIZEknob →llvmdsdl-uavcan-cpp-c-parity-sanitized), and over the Go harness's generated-C side (RunCGoParity.cmake, linux-only variant; Go-native decoders are memory-safe by construction and already panic-fail the parity harness). New coverage-guided libFuzzer lane (test/integration/NativeDecoderFuzz.c+RunNativeDecoderFuzz.cmake→llvmdsdl-native-decoder-fuzz) feeds arbitrary bytes into the generateddeserialize_entrypoints for the adversarial shapes (unionFrame, nested-delimitedport.List, variable-arrayExecuteCommand, narrow scalars) under ASan+UBSan, with auto-emitted valid seeds + a committed regression corpus (test/fuzz/corpus/native_decoder/). Bounded-runson PRs, deep run on the weekly cron; crash reproducers upload as CI artifacts. Degrades loudly to an ASan/UBSan corpus replay when a toolchain lacks compiler-rt libFuzzer (never a silent no-op). All builds use the Debug config soNDEBUGis absent and the runtimecopy_bitsasserts still fire. (Verified locally sans-ASan: harness compiles in all 3 modes, 200k coverage-guided runs clean, 7 valid seeds emitted; the ASan runtime itself was unrunnable only on the macOS Apple-Silicon dev box — a known shadow-memory startup hang — so the lane is intentionally Linux-only.) - [x] Reference-parity in CI, byte-exact incl. unions/floats, broad type coverage.
✅ Closed (2026-07-12) — reframed as corroboration, expansion retired by decision.
The authority hierarchy is now explicit: the Cyphal Specification is the truth;
spec/dafny/CyphalSerdes.dfyis the machine-checked oracle (op ordering, round-trip, read-path bounds safety — re-verified in CI); enforcement is spec-derived — the backend-contract gate (every backend's bodies are translations of the plan-body IR), the spec-derived primitive golden vectors (test/integration/primitive_vectors.txt, absolute wire bytes at the primitive level, all 5 runtimes), cross-backend byte parity (C↔{Cpp,Rust,Go,TS} + Python), and the sanitizer/fuzz lanes. Nunavut is a pinned peer implementation, not a reference: agreement is corroboration; a mismatch is an investigation adjudicated by the specification and is as likely to be a Nunavut defect as ours. The 10-case lane is retained, pinned, and blocking — with Nunavut pinned to exact SHAs it functions as a regression tripwire (a newly appearing mismatch implicates our change first), and the 10 cases already cover every wire-shape class: byte-exact non-float union, float union, fixed + variable arrays, nested composites, delimited types, narrow non-byte-aligned scalars, floats at all widths. The retired remainder (reg/UDRAL namespace,node.port.List) would have added instances of already-covered shapes, not new shapes — instance-level correctness across the whole corpus is enforced by shape-generic machinery (single-source step-tree sequencing + the verifier + cross-backend parity), so expansion would have increased exposure to Nunavut defects without adding spec-grounded evidence. (Honest residual: absolute byte layout above the primitive level rests on the primitive vectors + the 10 corroboration cases + single-source layout planning. If more absolute-layout confidence is ever wanted, the spec-consistent instrument is composite golden wire vectors hand-derived from the specification — not more Nunavut coverage.) (Pre-reframe history below.) (Runs in CI + byte-exact for all non-float and finite-float cases — 2026-07-10.) (a) Now runs in CI, non-silently. Thelinuxjob provisions pinned nunavut+pydsdl checkouts (.github/workflows/ci.yml; consumed as source trees viaPYTHONPATH, no pip) and configures with-DLLVMDSDL_REQUIRE_DIFFERENTIAL_PARITY=ON, so a missing reference compiler is a hard configure error, not a silent skip (the repo paths are overridable cache vars intest/integration/CMakeLists.txt). (b) Byte comparison is now always on (removed the opt-in--strict-float-byte-parityflag): all non-float types are byte-exact, and every float-carrying type is byte-exact too — includingReal32and theregister.Valueunion — after the C float codegen fix (2026-07-10). The scalar-float normalisation helper is now width-matched (f32 for 16/32-bit fields, f64 for 64-bit) instead of always f64, so the generated C keeps a float in its native width end-to-end rather than promoting todoubleand narrowing back; that round-trip was canonicalising signalling-NaN mantissa payloads and was the sole source of divergence from the reference. Verified byte-exact clean to 1,000,000 random iterations across all 10 cases. (C/EmitC fix:lib/Transforms/Passes.cpphelper type +lib/Transforms/ConvertDSDLToEmitC.cppcasts/decl. The Cpp/Rust/Go emitters were width-matched the same day (lib/CodeGen/HelperBindingRender.cpphelper + the serialise/deserialize callers inemitter/Cpp.cpp/emitter/Rust.cpp/emitter/Go.cpp), so all four native backends now keep floats native end-to-end and no longer canonicalise NaN via a double round-trip. TS/Python are inherently double-typed and cannot preserve float32 NaN payloads.) (d) Coverage broadened 6 → 10 cases (2026-07-10) across new wire shapes, all byte-exact and verified clean to 300k iterations:node.port.SubjectIDList.1.0(a byte-exact non-float tagged union — sparse list / bool[8192] bitset / total, the union coverage the float-variantregister.Valuecan't provide),pnp.NodeIDAllocationData.2.0(fixedbyte[16]+ variable-length optional),diagnostic.Record.1.1(nested composite timestamp +uint8[<=255]), andtime.SynchronizedTimestamp.1.0(narrow non-byte-aligneduint56); all 10 are byte-exact. Remaining: extend into thereg/UDRAL namespace andnode.port.List.1.0(~8.5 KB, needs the harness I/O buffers enlarged). (The Cpp/Rust/Go float helpers were width-matched for cross-backend consistency on 2026-07-10 — see P2.) - [x] Spec-conformance fix: reject out-of-range primitive widths at frontend + IR verifier.
(Done 2026-07-10.) The frontend now enforces the Cyphal Specification
primitive bit-length ranges (from the "Serialisable types" section) in
lib/Frontend/Parser.cppwith per-kind diagnostics, soint1,uint100,float8,int128, andvoid100are rejected instead of lowering to non-conformant MLIR. The spec gives signed and unsigned integers different minimums — signed [2, 64] ("ranging from 2 to 64, inclusive"; the single-bit case isbool), unsigned [1, 64] — plus float ∈ {16, 32, 64} and void [1, 64]. (The "int/uint 1..64" shorthand in rec 5 / G7 below was imprecise; the spec text and its integer-type table are the authority, andint1matches the lexical name patternint[1-9]\d*but is out of range.) Diagnostics read e.g.invalid signed integer bit length 1; must be in the range [2, 64]. As defense-in-depth,IOOp::verify()(lib/IR/DSDLOps.cpp) re-checks scalarbit_lengthagainst the same per-kind ranges (void also admits a degenerate 0-bit padding the lowering synthesizes and later drops), so no downstream pass or hand-authored IR can smuggle an out-of-range scalar into codegen. Covered by new negative+boundary cases intest/unit/ParserTests.cppand thetest/lit/dsdl-io-invalid-scalar-bit-length.mlir/dsdl-io-invalid-signed-bit-length.mlirverifier tests. (Nuance: zero-width formsuint0/int0/float0/void0are already rejected one level up by the grammar's[1-9]leading-digit rule, so they fail with a type-resolution diagnostic rather than the bit-length message.) - [x] Memory-safety hardening: LSP allocation cap; ~~remove
assert()-only guards from the releasecopy_bitspath~~. (Done 2026-07-10.) LSP allocation cap:JsonRpcStdioTransportnow bounds the framed payload — aContent-LengthabovekDefaultMaxContentLength(64 MiB, constructor-overridable) is rejected withContent-Length exceeds maximumbefore the payload buffer is allocated, and the header parser saturates instead of wrapping so a giant digit string can no longer overflowsize_tinto a small valid-looking length (lib/LSP/JsonRpcIO.cpp). Covered by new cases intest/unit/LspJsonRpcFuzzTests.cpp(oversized, overflowing, and at-cap-valid).copy_bitsasserts — premise withdrawn as overstated: the release read/write paths are bounds-safe by construction, not via asserts. Every generated deserialise helper callssaturate_fragment_bitsto clamp the read window to the buffer beforecopy_bits(runtime/dsdl_runtime.hget_bits/get_uxx), and every serialise helper checks buffer size and returns-SERIALIZATION_BUFFER_TOO_SMALLfirst; theassert()s incopy_bitsare API-contract / loop-invariant checks (non-null, non-overlap,size<=8) that are not the memory-safety mechanism, so their absence underNDEBUGcannot cause an OOB access. They are correctly kept (zero release cost) and do fire in the ASan/UBSan+fuzz lane, which builds Debug precisely so they stay live.
P1 — Required for a credible 1.0¶
- [x] Semantics overflow/DoS hardening (
Rational,BitLengthSet, capacity). ✅ Done (2026-07-11).BitLengthSetwas already overflow-hardened (__builtin_*_overflowwith saturation, defects BLS-D1…D16). This pass closes the other two:Rational(lib/Support/Rational.cpp) now evaluates+ - * /in 128-bit intermediates and poisons (sets a stickyoverflowed()flag, clamping to 0) when the reduced result leaves 64-bit range instead of invoking signed-overflow UB; comparisons cross-multiply in 128-bit so they are always exact; andnormalize()/gcd()no longer negate/llabsINT64_MIN. The evaluator (Evaluator.cpp) surfaces a diagnostic on any poisoned result and special-casesintPowfor bases in {−1, 0, 1} so a huge exponent can't spin the loop (a DoS), bailing as soon as the running product overflows for other bases;INT64_MIN % -1is also guarded. Array capacity: the length-prefix width is now computed as the bit-width ofcapacityrather thanceilLog2(capacity + 1), so an adversarialINT64_MAXinclusive-array bound no longer overflows the+ 1(the repeat math already saturated in the hardenedBitLengthSet). Covered by newEvaluatorTests/AnalyzerTestscases (overflow → diagnostic,1 ** hugeterminates, boundary comparisons, adversarial capacities analyse without crashing); verified UB-free under UBSan. - [x] Embedded-catalog integrity + freshness gating; runtime SHA-256 check.
✅ Done (2026-07-11). Freshness gating already existed — the release-blocking
llvmdsdl-embedded-uavcan-catalog-guardrunsgenerate_embedded_uavcan_mlir.py --check, regenerating the MLIR from the submodule and failing the build if the committedUavcanEmbeddedMlir.incis stale (plus a guard selftest). This pass adds the missing runtime SHA-256 check: the generator already baked akEmbeddedUavcanMlirSha256into the.inc, but nothing verified it.loadUavcanEmbeddedCatalognow computesllvm::SHA256over the embedded MLIR text and refuses to parse (hard error + diagnostic) on mismatch — catching binary/ memory corruption, tampering, or a text/hash drift. Factored into a pure, testableverifyEmbeddedCatalogIntegrity(unit-tested with the shipped blob, the known empty-string digest, and a rejected mismatch). Found and fixed a latent bug doing so: the generator hashedmlir_textbut the raw-string literal embedded"\n" + mlir_text(a leading newline), so the recorded hash never matched the bytes actually shipped; the generator now hashes the exact embedded content. Verified: unit tests, the freshness guard (regenerates byte-identically), the guard selftest, and the generator's own drift/roundtrip tests all pass. - [x] LSP concurrency correctness (DocumentStore mutex; analysis snapshot atomicity).
✅ Done (2026-07-11). DocumentStore was the real gap: it had no synchronisation and its
lookup()returned a rawconst DocumentSnapshot*into the internal map — a dangling-pointer / use-after-free hazard the instant a reader holds it across a concurrentclose()/rehash. It now carries astd::mutexguarding every operation, andlookup()returns a value copy (std::optional<DocumentSnapshot>) so no pointer into the map can escape. Proven under ThreadSanitizer: the 8-thread stress test (new inLspDocumentStoreTests) is race-free with the mutex and TSan flagsunordered_maprehash/read races the moment the locks are removed. Analysis-snapshot atomicity already holds and needed no change: analysis state (latestAnalysisResult_/analysisDirty_) is owned solely by the main message-loop thread, and the async Index worker receives a versioned immutable copy (scheduleRebuild(snapshotVersion, analysis_.buildIndexShards()), shards taken by value) rather than a reference to shared state — verified. (Note: there is no live race today — request handlers dispatch inline on the main thread and only a cancellable-sleep test method is offloaded — so this hardens the shared store for the offload-to-worker path the scheduler architecture is built toward. A dedicated TSan CI lane would be a worthwhile follow-up; today the concurrency test runs under the normal and ASan lanes.) - [x] Isolated cross-language runtime-primitive equivalence tests; populate/justify the wrapper allowlist.
✅ Equivalence tests done (2026-07-11). A shared golden-vector file
(
test/integration/primitive_vectors.txt, 47 vectors) is run against all five runtimes — C, Rust, Go, Python, and TypeScript — by thin per-language drivers (PrimitiveEquivalenceDriver.{c,rs,go,py,ts}, wired viaRunPrimitiveEquivalence.cmake→llvmdsdl-primitive-equivalence; TS is optional, gated on tsc/node/dsdlc). Each primitive direction is asserted on its own —float16_pack,float16_unpack, signed read/sign-extension (get_i8/16/32/64), unsigned read, andcopy_bitsat arbitrary bit offsets — so a bug in one direction can't be masked by its inverse. This immediately caught a real bug: both the Go and Rustfloat16_packmis-ported the C algorithm's load-bearing unsigned wraparound subtraction (Go zeroed via a guard, Rust usedsaturating_sub), so every finite float16 value serialised to garbage (1.0 → 0x0000). It was masked because the Go parity harness setrequireByteParity: falseon all 24 float-carrying cases. Fixed both runtimes (wrapping_sub/ unconditional wrapping subtraction), and flipped all 24 Go float cases torequireByteParity: true(they now agree byte-for-byte with C at 128 random iterations). The double-typed drivers (Python, TS) run the same vectors and explicitly report the small, principled skips they cannot represent at the raw-primitive level (Python: float16 overflow, which its primitive raises on while saturation happens one layer up; TS: float16-unpack NaN results, which a JS number canonicalises) — never silent, andprocessed + skippedmust still cover every vector. ✅ Wrapper allowlist justified (2026-07-11):runtime/semantic_wrapper_allowlist.jsoncarries ajustificationdocumenting why it is intentionally empty (all Rust semantic wrappers are template-generated and excluded by the validator; no backend ships a hand-written above-primitive wrapper), and the release-blockingllvmdsdl-runtime-semantic-wrapper-allowlistlane passes. - [x] Determinism gates that actually perturb (
PYTHONHASHSEED, locale/TZ, two toolchains) + auditunordered_*iteration in emitters. ✅ Audit + env-perturbation done (2026-07-11). Emitter audit came up clean: the CodeGen layer uses nollvm::DenseMap/DenseSet/SmallPtrSet(whose iteration is pointer/insertion ordered), everystd::unordered_map/unordered_setis a keyed lookup/membership structure that is never iterated to produce output, there is no locale- or time-dependent formatting, and the emitters alreadystd::sort/llvm::sort(8 sites) wherever emission order is derived from a set. So the generated text is already iteration-order-independent. Gates now actually perturb: the sixRunUavcan*Determinism.cmakegates previously ran two concurrent generations in the same environment (catching only concurrency/address nondeterminism); they now run the two generations under deliberately differentLC_ALL,TZ, andPYTHONHASHSEED, so byte-identical output proves environment-independence and would catch any future locale/TZ/hash-order dependence. All gates pass under perturbation. ✅ Two-toolchain lane done (2026-07-12); retired 2026-08-03 when the project began building its own toolchain — the lane depended on the two halves using different standard libraries, which shipping one toolchain ends. The class it detected is now excluded at the source bytools/determinism/check_unordered_iteration.py. The account below is kept as the record of what it did and why. The distro LLVM in CI is libstdc++-built, so a same-host-stdlib=libc++build cannot link it; the lane crosses hosts instead: the existing linux job (libstdc++, x86-64) and a newcross-stdlibmacOS job (Homebrew LLVM, libc++, arm64 — dsdlc-only build via thedev-llvm-envpreset, with anotoolassertion that the binary links libc++) each generate the full UAVCAN corpus for all six backends and record a canonical per-file SHA-256 manifest (tools/determinism/corpus_determinism.py); thecross-stdlib-determinismjoin job compares them byte-for-byte and fails with per-file detail. libstdc++ and libc++ implement differentstd::hash/bucket policies (and the hosts differ in arch and OS), so any futureunordered_*iteration leak into emitted text diverges the manifests. The five string backends compare strictly always; the MLIR/EmitC-routed C backend compares strictly too — the macOS job pins its Homebrew LLVM to the toolshed's major (llvm@22, asserted in-job so a Homebrew bump fails loudly instead of eroding coverage), and the join job passes--require-cso any major skew that slips through is itself a hard failure, never a silent skip. Bump both sides together, deliberately. Depfiles (*.d) are excluded (absolute host paths by design). Verified locally: generation is run-to-run and build-dir-to-build-dir deterministic on the libc++ side, the comparator's corrupted-manifest negative control fails with file-level detail, and the llvm-skew path warns and skips only C. -
[x] Object backend: escape/validate
targetTripleand staged paths. ✅ Done (2026-07-12). First, the reassuring part: the object backend already invokes the C/C++ compiler and archiver via argv (llvm::sys::ExecuteAndWait), never a shell, so there was no shell-injection vector (a hostile--target-triplecan only ever be an inert--target=payload) — "escape" was moot; validation was the gap. Added:--target-tripleis now checked against a conservative charset (hyphen-separated[A-Za-z0-9._-], no leading dash, ≤128 chars) so whitespace/path/shell metacharacters are rejected up front with a clear diagnostic;--obj-archive-namemust be a single safe filename component (no path separators or..), closing an archive-path traversal where../../evilwould escape--outdir; and every derived object/archive path is containment-checked (isPathWithinRoot) to refuse writes outside the output root as defense-in-depth behind the input validation. New negative cases intest/lit/cli.txtprove the malicious triple and traversal archive name are rejected while a valid triple still passes. -
[x]
obj --obj-abi-language cpppublishes a header set that does not build from-I<outdir>. ✅ Done (2026-08-25). Both bugs had one cause: in this lane the C output is staged underc/inside the C++ stage, and the generated ABI headers include it from there, but it was published relative to the C stage root — which flattens thec/prefix away. One expression (cPublishStageRoot) picks the C++ stage root in this lane, and the C headers land atc/<ns>/X.hwhere the includes already point. The double-writtendsdl_runtime.hfalls out with it: the real runtime now lands atc/dsdl_runtime.h, so the forwarder at the root no longer overwrites anything.--list-outputsreports the new paths, and the published layout is now documented in object.md. The smoke test compiles against<outdir>instead of.obj_stage_cppand asserts the two runtime headers are the right way round — a compile alone would not catch a missing forwarder, since a quoted include resolves against the including file's own directory first. Verified load-bearing by reverting the fix and watching the test fail. Found 2026-08-23 while mapping the lane's C/C++ boundary for the identifier-naming work; reported as two independent bugs inpublishStagedHeaders(lib/CodeGen/ObjectEmitter.cpp), neither covered by a test. They turned out to be one. - The C headers publish relative to the C stage root and the C++ headers relative to the C++
stage root, so the C headers land at
<outdir>/<ns>/X_1_0.hwhile the publishedabi/<ns>/X_1_0_abi.hppincludes them as"c/<ns>/X_1_0.h"— a path that exists only in staging. A consumer given-I<outdir>cannot compile the artefact they were handed. <outdir>/dsdl_runtime.his written twice: first the real C runtime header (ObjectEmitter.cpp:516), then overwritten by a forwarder reading#include "c/dsdl_runtime.h"(:641), which dangles for the same reason.
llvmdsdl-obj-cpp-backend-smoke misses both because it compiles against
<outdir>/.obj_stage_cpp — the staging tree — rather than the published output. Fixing the test
to use the published tree is the part that keeps it fixed.
- [ ] Complete aliasability. (Found 2026-09-17; G6. Plan:
ZERO_OVERHEAD.md.)
dsdl-annotate-aliasabilitydecides whether the wire form of a type is a flat byte image.try_deserialize_view_is useful only if the host struct is that same byte image. The two are different properties and nothing connects them, so the flag is wrong for 9 of its 63 eligible sections on arm64 —uint56anduint40stored in auint64_t,float16in afloat, and wire padding the struct omits. The pass also misdiagnoses: a composite carriesbit_length = 0and thebitLength <= 0test precedesisComposite(), so nested composites reportinvalid-bit-length— 74 of the 118 ineligible verdicts, and thecomposite-fieldandunion-typebranches are unreachable.
The plan separates the two properties the one flag was covering — W (the wire is a contiguous
byte run, decidable from the schema) and H (the generated structure is that run, an ABI fact)
— and sizes them over the catalogue: the old flag accepted 63, H is 54, W is 107. It adds an
@aliasable directive asserting W so a performance-critical type fails at the schema rather than
degrading silently, with generated static assertions covering H on the consumer's own target.
✅ Phases 0 through 2 landed 2026-09-17. The verdicts are decided once in the analyser, carried
as plan attributes, and re-derived by dsdl-verify-alias-layout rather than computed twice. H is
checked against what the compiler lays out by llvmdsdl-alias-layout-reality, which found no false
claims over the catalogue. @aliasable ships as a documented llvm-dsdl extension, upstream later
(docs/reference/commands/dsdlc.md); the differential corpus is the public regulated submodule and
cannot contain the directive.
Reshaped 2026-09-18 by an implementation review, which changed two of the remaining phases
before they were built. The bulk copy cannot reuse dsdl.bit_write: in Rust, Go and TypeScript
that op is a per-bit loop over a bool container, so it needs its own op and a per-target capability
bit. And the decode-free read surface becomes generated accessor bodies rather than a second
packed type — one dsdl.read_bits at a constant offset, which every backend already spells, so it
reaches TypeScript and Python too, sidesteps the alignment and object-lifetime problems, and drops
the width split. The review also added a candidate lint, so an author learns that a field will
block the fast path while it is still cheap to change. Phases 2.1 (revisits), 2.2 (the lint) and 6
(container views) are new; 3 through 5 are revised.
✅ Phase 3 landed 2026-09-18. On C, obj, C++, Rust and Go a host-image type's serialise
and deserialise are one move of the object's bytes — Quaternion's read went from 84 instructions
to 35 — produced by a rewrite pass over the canonical field-wise bodies, gated on the target
triple being little-endian, and held to a per-triple instruction-count baseline. Every host-image
structure asserts its size and each member's offset on the target it is compiled for; the
assertions found that the C++ PMR profile's memory-resource pointer made such a structure wider
than the wire, which is fixed by giving a host image none. This qualifies a claim elsewhere in
this document: serialize_ is host-endianness-agnostic for the field-wise body, and the folded
body is little-endian only — the generated code refuses a big-endian build with the reason and
the fix, rather than falling back.
✅ Phase 4 landed 2026-09-18. Every wire-flat type has field accessors in all six
languages, built as bodies beside the three the plan already had: a getter that reads one field
off a serialised buffer at its fixed offset, in the member's own type, and a setter that writes
one; an element accessor takes an index, and a nested composite's getter answers the buffer from
its offset so the nested type's own accessors compose on it. A getter answers what deserialize_
puts in the field on every buffer, short ones included, which a six-language lane holds it to
on nine regulated types. The object lowering now takes the target's endianness and lowers a
byte-aligned scalar of a register's width to a bounds check and one load or store, which the
field-wise bodies inherit as well: Padded's deserialise fell from 56 instructions to 39 on
AArch64.
✅ Phase 5 landed 2026-09-18. --aliasable-only emits the accessors and neither the object
type nor the serialisation. It holds every targeted type to @aliasable, or to being nested by a
type that carries it, and names each that is neither. A lane compiles the output standalone on all
seven targets and reads a buffer through the outer type's composite getter and the inner type's
field getter.
✅ Phase 6 landed 2026-09-19. --aliasable-views holds a composite field of an @aliasable
type as a view of the buffer the holder was deserialised from, in every language: the holder's
deserialise skips the record and its serialise copies the view, a short buffer leaves a short
view read as zeros, and the record's accessors read the view in place. C and C++ hold the
runtime's pointer and size, Rust a borrowed slice that gives the holder a lifetime, Go, TypeScript
and Python their own slices; an array of the type is a view per element. A lane holds all seven
targets to that contract, on a scalar member and on the elements of a fixed and of a
variable-length array, and the instruction
lane baselines the holder beside the callee the view removes. A union whose options are all flat
and of one length has the same accessors, its tag reached as a member named _tag_; the
catalogue has no such union, so a fixture holds all seven targets to it.
P2 — Maturity / maintainability¶
- [x] Reduce per-backend control-flow duplication — directly strengthens G1. ✅ Done (2026-09-11): every backend's serialise and deserialise bodies are translations of the
build-dsdl-plan-bodiesIR through one translator (lib/CodeGen/BodyTranslator.cpp); a spelling per language carries surface idiom only, andctest -L backend-contractfails a backend whose output does not follow a perturbed body. Seedocs/development/backend-translation.md. This was the G1 end-state. - [x] Remove dead
dsdl.field/dsdl.constantops; add proactive verifiers in lowering. ✅ Done (2026-07-12) — premise corrected + verifiers made proactive. The review's "dead ops" premise was wrong:dsdl.field/dsdl.constantare not dead — they are schema-space introspection/documentation ops, emitted byLowerToMLIR, round-tripped through the embedded UAVCAN catalogue (UavcanEmbeddedCatalog.cppreadsdsdl.constant), and asserted bycomments-propagation.txt. Removing them would break doc propagation and the catalogue. What was stale: the ODS typed classes were vestigial (created generically viaOperationState, never via the generated builder) and the ODS omitted attributes the lowering actually sets (c_name,section). Fixed by making the ops first-class and verified rather than deleting them: ODS now declares the real attributes (optionalc_name/section) withlet description, and both ops carryhasVerifier+FieldOp::verify()/ConstantOp::verify()(non-emptynamefor non-padding fields — padding is anonymous by construction — plus non-emptytype_name, andvalue_textfor constants). Verifiers are now proactive for every backend:lowerToMLIRcallsmlir::verify()on its own output under a scoped diagnostic handler, so all op verifiers (serialization_plan/io/align/field/constant) fire at lowering time — previously they ran only on the C path via its pass manager, so the native backends consumed unverified IR (the concrete G6 "verifier-first is actually post-hoc" gap). Adding the verify pass immediately caught a real over-strict invariant (padding fields legitimately have empty names), which the fix accommodates. Byte-identity preserved (generic creation retained, so no emitted-text change; full UAVCAN corpus byte-identical across all six backends); embedded catalogue +comments-propagationstill green. Negative test:test/lit/dsdl-field-invalid-empty-name.mlir. This closes the G2 "dead ops" note and the G6 "post-hoc verifier" critique. - [ ] Split the largest emitters (Ts ~2.1k, Cpp ~2.0k LOC) into syntax/planning/naming modules.
- [x] LLVM-version lock + multi-version EmitC testing; LSP logging for post-mortems; document the LSP "AI" surface's data flow.
LSP adversarial data-flow / robustness audit done (2026-07-12) — four DoS defects
found and fixed in code the review had rated as done. All are unbounded-growth on
attacker-controlled JSON-RPC input: (1)
TelemetrykeyedrequestCounts_by the raw method string and recorded telemetry for every request including unknown methods, so a client streaming distinct method names grew the map without limit — now caps distinct keys and buckets overflow under<other>; (2) the AIAiAuditLoggerretained up to 256 records with unbounded per-record detail (serialised tool args, up to the 64 MiB frame cap ≈ 16 GiB) — detail now capped at 4 KiB after redaction; (3)JsonRpcStdioTransportread the header section with unboundedgetlineand unlimited header lines before the Content-Length cap applies — now per-line (8 KiB) + total-header (64 KiB) caps; (4)RequestSchedulerhad no bound on queued + in-flight requests — now a pending cap (4096); (5) thedsdldrun loop terminated the whole server on anyreadMessagefailure, including a well-framed message whose payload is merely invalid JSON (stream still synchronised) — so a singleContent-Length: 2\r\n\r\nxxkilled the server (availability DoS + LSP-spec violation).readMessagenow flags recoverable failures and the loop replies-32700 Parse errorand keeps serving; verified end-to-end (bad frame theninitializeboth handled). Regression tests added for all five. Reviewed and found clean: the Server request dispatch and param parsers (position/range/uri) are optional-based and guarded (negatives rejected, empty-container guards present, fixed-size array indexing); the redaction/gate consistency holds. ✅ LLVM-version-lock write-up done (2026-07-15) —docs/reference/guarantees/supply-chain.mdrecords why the major is a semantic input to the output (MLIR/EmitC text may vary across MLIR majors, so the lock is what makes the reproducibility guarantee meaningful), where it is enforced (CI pins on both hosts;--require-cturns a major skew into a hard failure rather than silently retiring C coverage), and that the build itself does not assert it. ✅ The "MLIR verify diagnostics leak to stderr" concern was investigated and does not exist (2026-07-15): the solemlir::verifycall is wrapped in aScopedDiagnosticHandlerwhose callback returnssuccess(), so diagnostics are captured into the diagnostic engine and never reach the default (stderr) handler; the LSP's partial-model path does not verify at all. The stderr text seen in test output comes from negative-path tests that mutate IR deliberately. ✅ LSP structured logging done (2026-07-15) —docs/reference/lsp/logging.md.dsdldnow emits one JSON object per line to stderr (stdout carries the protocol frames and must never be interleaved; the logger serialises writes so records from the main thread and the scheduler worker stay intact). This also makes an existing dead knob real:ServerConfig::traceLevelwas parsed and documented as driving "server logs and telemetry" but nothing consumed it — verbosity is now gated by it, settable viainitializeparams, the$/setTracenotification (previously unhandled), or thetracesetting.requestrecords cover synchronous and scheduler-completed requests alike by hooking the single telemetry choke point, carrying method/latency/outcome; failures surface aserror_responsewarnings. Records deliberately carry no document text or secrets. Replaces the ad-hoc[dsdld][telemetry]line that printed unstructured text on every request regardless of trace level. ✅ LSP AI-surface data-flow document done (2026-07-15) —docs/reference/lsp/ai-data-flow.md, traced from code rather than asserted. It answers the operator's real question ("does my DSDL source leave my machine?"): no —OfflineAiProvideris the onlyAiProviderimplementation, it runs in-process, and there is no network code in the surface at all;aiProvider_has exactly one invocation site, which is the seam a future remote provider would have to cross. It records the bounds on what reaches the provider (MaxSnippetBytes = 640of source, 8 diagnostics, 24 symbol hints, plus structural facts), the closed four-entry read-only tool allow-list, the edit gate (ApplyWithConfirmationonly), and retention (audit log is in-memory only — no filesystem or network persistence — redacted before a 4096-byte cap so truncation cannot unmask a secret, ringed at 256 records for a ~1 MiB bound). Honest about the limit: redaction is pattern-based masking of known secret shapes, not a proof of absence. ✅ LLVM-version lock made authoritative + multi-version EmitC testing descoped (2026-07-17). Maintainer decision: pin to LLVM 22 (23 is not yet stable). The lock is now enforced at configure time (CMakeLists.txtassertsLLVM_VERSION_MAJOR == LLVMDSDL_REQUIRED_LLVM_MAJOR, default 22, with a-DLLVMDSDL_ALLOW_LLVM_MAJOR_MISMATCH=ONescape hatch for evaluating a future major) rather than by CI alone, so an accidental build against another major fails loudly instead of silently producing divergent EmitC. "Multi-version EmitC testing" is explicitly descoped: it competes with the lock (the lock exists precisely because EmitC text may vary across MLIR majors), so guaranteeing reproducibility against one major is the strategy, not chasing stability across several. Rationale and the path to revisit are recorded indocs/reference/guarantees/supply-chain.md. This line item is now complete. - [x] Security review of union-tag handling across backends; supply-chain/SBOM for release artifacts.
✅ Union-tag review done (2026-07-12); SBOM done (2026-07-15). The review was cheap because
the prologue is now single-source (
buildUnionSectionSteps+ five spellings + the C/EmitC path). One real defect found and fixed (UT-1, high — decode-time type confusion from untrusted bytes): the union tag was stored in a hardcoded 8-bit field (u8/uint8/std::uint8_t/uint8_t) in every native backend (C string, C/EmitC, C++, Rust, Go) and the object/ABI backend, while the compiler computes a 16-bit wire tag for unions with 257–65536 options (the mask helper is literally& 65535). Effect on a spec-legal >256-option union: serialise cannot represent options ≥256, and deserialise truncated the 16-bit wire tag into the 8-bit store → wire tag 256 decoded as option 0 (type confusion), or a corrupted round-trip (the C/EmitC path dispatched on the untruncated value but stored a truncated_tag_). Fix: tag storage now tracksresolveUnionTagBits(unsignedStorageType(tagBits)/ the EmitC(uint16_t)store cast); a no-op for every ≤256-option union, so the full UAVCAN corpus is byte-identical across all six backends and the ABI/object lanes. Behaviourally verified: a 300-option union now round-trips option 256 in generated C (tag=256preserved; previously decoded astag=0). Regression test:test/lit/union-wide-tag.txtasserts the ≥16-bit tag storage in C/C++/Rust/Go. Reviewed and found sound (no change): validate-before-dispatch bounds the tag against the option count; the bad-tagdefaultarm returnsBAD_UNION_TAGin every backend; the mask width tracks the tag width; the helper-optional->derefs are guarded by the native skeleton's missing-helper fallback (union dispatch is unreachable when helpers are unbound); TS (numeric-literal discriminant) and Python (unboundedint) are inherently truncation-safe. Peer cross-check (2026-07-12, pinned commits): UT-1 was ours alone. pydsdl (_composite.py, pinneda1506ce) computes the tag width as2 ** ceil(log2(max(8, (N-1).bit_length())))— mathematically identical to ourpow2ceil(max(8, ceilLog2(count))), with its ownassert tag_bit_length in {8,16,32,64}— so our tag width was always spec-conformant (a >256-option union is a 16-bit tag in both; the defect was strictly the hardcoded 8-bit storage, not the wire width). pydsdl emits no storage, so it cannot have the bug; Nunavut (pinned37095ab,lang/c/templates/definitions.j2:124) declares the member as{{ t.tag_field_type | type_from_primitive }} _tag_;— deriving the C type from pydsdl's computed width, so it already emitsuint16_t _tag_;for a wide tag. Both peers are correct; the spec adjudicated against us. (No standard UAVCAN type has a >256-option union, which is why neither the differential-parity lane nor real-world use ever exercised it — the bug lived only in the tail we reached by construction.) ✅ SBOM done (2026-07-15): every build emits a CycloneDX 1.5 document (cmake/GenerateSBOM.cmake,sbomtarget inALL, installed to<datadir>/llvm-dsdl/with thebincomponent) recording the tool version and exact source commit, the LLVM/MLIR version actually linked (not an aspiration), zstd when present, the first-party runtime, and the pinned submodule commits scopedexcluded(build/test inputs, not shipped). The generator carries no wall-clock timestamp or random serial, so it regenerates byte-identically and does not undercut the reproducibility lanes. Seedocs/reference/guarantees/supply-chain.md. - [x] Float serialisation: avoid the
float→double→floatround-trip. ✅ C/EmitC done (2026-07-10) — the scalar-float helper is width-matched (f32 for 16/32-bit, f64 for 64-bit), so C keeps floats native end-to-end and preserves signalling-NaN payloads, giving byte-exact reference parity for all float-carrying types (register.Value,Real32) at 1M iterations. ✅ Cpp/Rust/Go done (2026-07-10) — the same width-match is now applied to the shared float helper (lib/CodeGen/HelperBindingRender.cpp:const float/f32/float32for 16/32-bit,double/f64/float64for 64-bit) and every serialise/deserialize caller (emitter/Cpp.cpp,emitter/Rust.cpp,emitter/Go.cpp), so all four native backends keep floats in native width end-to-end and no longer canonicalise signalling-NaN payloads via a double round-trip. Verified against the uavcan-cpp-c-parity, uavcan-c-go-parity, uavcan-c-rust-parity, and generation suites. Locked in by directed signalling-NaN payload regression cases added to the cpp-c, c-rust, and c-go parity harnesses (feed the sNaN wire bytes01 00 80 7F, deserialise→reserialise, assert the quiet bit stays clear); a mutation reintroducing the round-trip makes them fail. TS/Python are out of scope: both are inherently double-typed and cannot preserve float32 NaN payloads, so full cross-language NaN byte-parity is not achievable regardless. (Open spec question about what "the original value will be preserved" means for a NaN atfloat16width is captured inNAN_PRESERVATION_QUESTION_FOR_MAINTAINERS.md.)
The MLIR pipeline, the hardened frontend, the runtime, and the differential-testing harness are built and largely sound. What stands between this and "high-assurance public release" is mostly (a) making the verification as strong as the documentation already claims it is, and (b) relabeling the few claims that are inherently marketing. That is a focused, weeks-not-years effort, and most of it is additive testing rather than rearchitecting.
6. Release Phasing (added 2026-07-12)¶
Everything above — P0 → P1, with P2 as maturity work — is the alpha. Language-target expansion is deliberately deferred past alpha so that a coherent first version reaches testers before the backend set grows, with the alpha → beta-1 boundary reserved as the sanctioned place to absorb breaking changes.
-
[ ] Alpha — limited release for initial feedback. Scope = the current roadmap (P0 release-blockers first, then P1 for a credible cut; P2 as capacity allows). The target-language set is frozen at the current six — C, C++, Rust, Go, TypeScript, Python — plus their existing profiles (C++
std/pmr/autosar). Goal: get a good first version into alpha testers' hands and gather real usage feedback. No new language backends in alpha. -
[ ] Beta 1 — add one new target from the deferred set. After alpha, explore and prioritise the four candidates below and (probably) pick one to add first; the choice is driven by alpha feedback and demand, not decided now:
- Ada / SPARK — safety-critical forcing function and the only shipping DO-178C-Level-A answer; intentionally breaks the C-syntax, exception, and integer-storage assumptions, so it doubles as the acceptance test for the shared-render abstraction. Cost: paid qualification path (GNAT Pro / SPARK Pro), Pascal-family emitter (largest template departure).
- Ferrocene — a
core-only /no_stdprofile of the existing Rust backend compiled by a certified toolchain (ISO 26262 ASIL D, IEC 61508 SIL 3; DO-178C not yet achieved). Lowest syntax risk, highest ROI, and the one candidate with real first-party demand (no-std Rust). - MicroPython — a bignum / GC /
uctypesprofile of the existing Python backend; convenience and education nodes, non-safety-critical. - WASM — deployment target for edge / app-processor Cyphal nodes; reachable through the Rust backend (and, later, a TinyGo profile of Go);
i32/i64-only, so it needs explicit narrowing codegen. -
Tenet to revisit when scoping: pin the execution model (imperative, mutable in-place buffer, monotonic bit cursor) rather than "C-informed syntax," and let "could Ada slot in as a backend?" be the test of whether the body translator (P2) is language-neutral.
-
[ ] Beta 1 roadmap — authored after alpha feedback lands. Not written now, by design. Breaking changes are expected and acceptable across the alpha → beta-1 boundary — this is the window to rework interfaces and revise whatever alpha exposes, before API stability starts to matter.