Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Language bindings strategy (Phase 3)

Requested 2026-08-02 by the project owner: an analysis of which languages actually benefit from a dstu-core binding, what to bind and how, what engineers need to consume it, a project-structure placement, and a phased roadmap built with advisor() input, executed in small committed steps. This document is the durable record of that analysis — docs/TASKS.md tracks the same work at the task-checklist level (Phase 3 section, T-158 onward); this file is the reasoning behind it, not a duplicate of the checklist.

docs/dstu-crypto-project.md’s “Second priority” section already named the five core languages (Python, JavaScript, Java, .NET, C++) before this document existed — this is the plan for how, not a re-litigation of whether.

Popularity analysis — why this order, not TIOBE rank alone

TIOBE (July 2026): Python #1 (18.9%), C #2, C++ #3, Java #4, C# #5, JavaScript #6, Rust newly #10. Raw rank is a weak signal for this specific library’s audience, though — a DSTU crypto library’s real consumers skew toward PKI/enterprise/security tooling, not general web/app development. Two pieces of direct evidence from this project’s own oracle map (docs/ORACLES.md) outweigh TIOBE rank for ordering Java/.NET: UAPKI (a real Ukrainian PKI stack, state-expertise-certified once) already ships Java/Kotlin bindings, and Bouncy Castle .NET is already a verification oracle used in this repo’s own test harnesses. That is direct evidence of where real DSTU-consuming demand already sits, not a rank-based guess.

Net ordering, and why each sits where it does:

  1. Python — not chosen for TIOBE rank #1 alone, but because PyO3 + maturin is the most mature direct-Rust-binding toolchain that exists today, so it validates the whole pipeline (workspace member → build → package → local test → examples → CI) with the least incidental FFI complexity. Every later binding reuses this template rather than re-deriving it.
  2. A C ABI crate — not a language binding itself, but the shared foundation C++, .NET, and (pending a spike, see below) Java need. This is dstu-crypto-project.md’s “On the horizon” C-ABI idea finally becoming real, but scoped strictly as “serves our own bindings” — the speculative “UAPKI itself could adopt this instead of its own C implementation” idea stays exactly as speculative and unscheduled as that section already states; this document does not schedule it.
  3. .NET — P/Invoke over the C ABI crate. No new Rust-side glue beyond the C ABI itself.
  4. Java — real Ukrainian-PKI demand evidence (UAPKI), but needs one implementation-choice spike first (below) before locking an approach.
  5. JavaScript (Node) — napi-rs, a direct-Rust binding shaped like Python’s, deliberately not built second despite the shape match, because Node’s actual audience (web/app dev) overlaps least with this project’s demonstrated demand (PKI/enterprise). Scope explicitly Node-only, confirmed with the project owner 2026-08-02, see D-118: a browser-usable target (Web Crypto API-style in-browser TLS/signing was the concrete comparison raised) needs a genuinely different toolchain (WASM via wasm-bindgen, not napi-rs — napi-rs binaries don’t run in a browser at all) and is deliberately not scheduled now, not silently assumed either way.
  6. C++ — consumes the same C ABI crate/header directly; no separate Rust glue needed.

Two additional languages the project owner asked to include, deliberately placed after the original five and not interleaved with them (no equivalent Ukrainian-PKI demand evidence exists for either):

  1. PHP (Phase 8) — TIOBE ~#8, large web-backend footprint (Laravel/WordPress), essentially no presence in crypto/PKI tooling. Rides the already-built C ABI (ext-php-rs as a real extension, or a plainer FFI-extension path over the same header) rather than justifying its own Rust-side binding.
  2. Ruby (Phase 9) — smaller than PHP by TIOBE rank (~#12-15), but a somewhat stronger security/ops-tooling footprint (Metasploit, DevSecOps scripting) than PHP has. Binds the Rust crate directly, like Python/Node, via magnus/rb-sys — the current standard for production Rust-backed gems, not through the C ABI.

Build order revised 2026-08-02 (D-121/D-122) — the analysis above stays, the ordering it drove doesn’t

The popularity analysis above is kept verbatim, not rewritten — it was correct evidence, just aimed at the wrong question. It asked “where does real DSTU demand already exist,” and answered Java/.NET via UAPKI/Bouncy Castle. The better question for this project’s own ordering is “where does a gap exist that only this project’s zero-config crypto_* surface fills” — and Bouncy Castle/UAPKI already serving Java/.NET means this project’s marginal contribution there is real but smaller than in a language with no DSTU library at all (Node, Ruby, PHP, and now Go — none of which have an incumbent the way Java/.NET do).

Revised order: T-49 (Python, done) → T-50 (Node) → T-160 (Ruby) → T-159 (PHP, committed to ext-php-rs specifically so it’s a direct binding like Node/Ruby, not gated on the C ABI crate below) → T-158 (C ABI crate, built once actually needed by the group below) → T-52 (.NET) → T-51 (Java) → T-163 (Go, new - see its own section below; needs the C ABI too, since no Go binding toolchain matches PyO3/napi-rs/magnus’s maturity) → T-53 (C++, reordered again same day - D-123 - to build after Go specifically, the owner’s explicit preference) → T-162 (docs, last).

Dart, raised in the same conversation, is explicitly deferred, not silently assumed either way (D-122) — same reasoning as Node’s own browser/WASM scoping (D-118): Dart’s primary audience (Flutter mobile/web) overlaps least with this project’s demonstrated PKI/enterprise/security- tooling demand, so it doesn’t earn a place ahead of the languages that do.

What to bind and how — the three forks, resolved

Fork 1 — C ABI vs. native FFI, resolved by tooling maturity, not preference

Python (PyO3) and Node (napi-rs) bind the dstu-core Rust crate directly — routing either through a C ABI would double-marshal data for no benefit and lose idiomatic types (Python bytes, JS Uint8Array) for nothing. C++ and .NET consume the C ABI crate directly instead: C++ via the generated header + link, .NET via P/Invoke. Java gets an explicit spike step (Phase 4, step 1) comparing the jni crate (write the JNI layer directly in Rust, no hand-written C shim) against JNI-over-the-C-ABI, before committing to either — record the outcome in docs/DECISIONS.md when that spike runs. Ruby follows Python/Node’s direct-binding shape (magnus); PHP was planned to follow C++/.NET’s C-ABI-consuming shape (ext-php-rs or the FFI extension) here, but the actual T-159 implementation binds dstu-core directly via ext-php-rs (confirmed by its own Cargo.toml dependency, not this plan) - flat dstu_core_*-prefixed globals matching ext-sodium’s own naming (D-142), same direct-binding group as Python/Node/Ruby in practice. This paragraph is left uncorrected below as the historical record of what was planned; treat the per-binding Cargo.toml as the source of truth for what a binding actually links against, not this section (found stale doing T-181’s own PHP work, 2026-08-06 - the T-181 phase entry below reflects reality).

Fork 2 — crypto_sign (DSTU 4145) exposure: uniform across every binding

dstu-crypto-project.md’s original “Second priority” text says: “Do not separately reimplement DSTU 4145 in the native core — for Java/.NET, integrate/wrap Bouncy Castle… for Rust, port it while relying on Bouncy Castle as a second verification oracle.” That guidance predates hazmat::dstu4145/dstu_core::crypto_sign actually existing — they’re now fully implemented and verified in Rust (per-Annex-B.1 worked example, dual-oracle cross-checked against real Bouncy Castle Java/.NET, docs/DECISIONS.md D-25/D-46). Bouncy Castle’s role today is verification oracle only, already used that way in tests/oracle-harness/. There is no remaining reason for a Java or .NET binding to route signing through Bouncy Castle instead of this project’s own audited crypto_sign — a binding that silently omits crypto_sign, or reimplements it against a different library per language, is strictly worse than one that calls the same Rust implementation every other binding calls. Resolution: every binding exposes the same crypto_* surface, crypto_sign included, uniformly. See docs/DECISIONS.md D-115 for the citation.

Fork 3 — package naming: uacrypt / dstu-core everywhere

Confirmed with the project owner this session: match the existing CLI binary (uacrypt, D-36) and crate (dstu-core) names on every registry, using each registry’s own idiomatic spelling (underscore vs. hyphen) rather than inventing a new brand or an artificial dstu-ua- prefix. Checked exact-name availability directly against each registry’s own API (not a search engine, which under-indexes empty results) on 2026-08-02:

RegistryName checkedResult
PyPIuacrypt404 — free
PyPIdstu-core404 — free
PyPIdstu_core404 — free
npmuacrypt404 — free
npmdstu-core404 — free
NuGetuacrypt404 — free
NuGetdstu-core404 — free
Maven CentralartifactId uacryptnumFound: 0 — free
Maven CentralartifactId dstu-corenumFound: 0 — free

No collision with li0ard (D-07, excluded as an untrusted supply-chain source): their TypeScript packages live under the npm scope @li0ard/kalyna, @li0ard/kupyna, @li0ard/strumok — a different namespace entirely from the unscoped dstu-core/uacrypt names this project would use. A future consumer typing the unscoped name has no path to land on @li0ard/* by mistake.

What engineers need — the per-binding checklist

Every binding, regardless of language, ships all of the following before it’s considered done — this is the template every phase below instantiates:

  • The same crypto_* API surface (secretbox, secretstream, auth, kdf, generichash, stream, sign, pwhash where the feature is enabled, randombytes) — not a subset, per Fork 2.
  • Idiomatic to the target language, per docs/cross-language-style-guide.md (casing, error shape, resource cleanup, doc-comment format) — that document is the style authority; this document doesn’t re-derive its conventions.
  • “Install and forget” — zero-config API, no knobs to misconfigure. Same libsodium-style hard- defaults philosophy the core already applies (crypto_secretbox/crypto_secretstream’s internally-generated nonce, D-47’s “delete the knob”). A binding’s public surface takes a key and a message and returns a result — no mode/nonce/IV/padding parameter for the consumer to get wrong, no setup step beyond import/require/using + one key-generation call. This is a functional requirement, not just documentation quality — if a binding needs a config object or an init call beyond constructing a key, that’s a design defect to fix before the binding ships, not something to explain away in a README.
  • Prebuilt binaries — never “clone and build it yourself” for the binding’s own consumer. Same bar uacrypt itself already clears (T-18/T-119: GitHub Release binaries for Windows/Linux/macOS, “no Rust toolchain required on their side” per D-12’s own scope note) — a consumer of the binding installs a package and never invokes cargo build themselves. Per language: Python — manylinux/ macOS/Windows wheels via maturin; Node — prebuilt .node binaries per platform via napi-rs’s cross-compile; Java — a native library bundled per OS/arch classifier (or one fat JAR); .NET — a package with runtimes/{rid}/native/ per platform; C++ — prebuilt static/dynamic libs alongside the header, or a one-line CMake FetchContent; PHP/Ruby — a prebuilt extension binary where the ecosystem supports it, source build only as a fallback. This is about the packaging mechanism and applies to local/CI-artifact installs immediately — it is independent of, and does not wait on, the separate registry-publish authorization gate below.
  • crypto_secretstream gets an idiomatic stream/pipe wrapper per language, not a raw push/pull loop the consumer manages themselves. See D-118: the same “.NET CryptoStream/GZipStream, Node stream.Transform, Python file-like object, Java InputStream/OutputStream, C++ istream/ostream” shape every one of those ecosystems already has for exactly this kind of transform-a-stream operation. A consumer wires a source stream to a destination stream (or File.Encrypt(inPath, outPath, key)-style helper for the common case) and chunking, tag framing, and rekeying stay entirely invisible — this extends the “install and forget” requirement above to the mechanics of streaming, not just to the absence of crypto knobs. This adds no new configuration surface — D-47’s “delete the knob” still holds; the “wider” instinct that prompted this is satisfied by which primitive to call (secretbox for one message, secretstream for a file/stream, sign for a signature — already all in scope), not by new tunables inside any one of them.
  • Three test categories (already this project’s standing rule, D-64/D-65): (1) correctness against the same vectors/oracles the Rust core already uses, (2) rejection — tampered ciphertext/tag/nonce, wrong key, (3) misuse — bad lengths/paths, empty input, no partial output on failure. “Round-trip works” alone is category 1 only, not sufficient coverage.
  • Category 1 specifically must run the actual official vectors, not just round-trip against itself. Each binding’s local test suite loads and runs the same crates/dstu-core/tests/vectors/{kalyna,kupyna,strumok,dstu4145}/*.json files the Rust tests already use, through the binding’s own public API — one source of truth, no hand-copied duplicate vector data per language to drift out of sync (the same “test-vector fix needs a citation, not just matching numbers” discipline CLAUDE.md already applies to the Rust tests themselves). Where a language’s ecosystem makes reading JSON test fixtures awkward, generate that language’s fixture format from the JSON at test-build time — never hand-transcribe the numbers.
  • A runtime self-test function the binding’s own consumer can call, not just a dev-time test suite. See D-117: dstu_core gains one shared selftest module that re-runs the official KAT vectors against the live compiled code and reports pass/fail (which primitive failed, if any). Every binding exposes a thin, idiomatically-named wrapper around that single implementation (dstu_core.selftest() in Python, selfTest() in Node/Java/.NET, dstu_selftest() in the C ABI) — built once at the core, not reimplemented per language. This lets a consumer verify their exact installed binary is producing correct outputs on their exact platform before trusting it with real data, the same “don’t just trust it compiled” instinct this project already applies to itself via dual-oracle verification.
  • A local test suite in that language’s native framework (pytest, xUnit, JUnit, node:test, a small C/C++ harness, PHPUnit, RSpec/Minitest) — runnable without any other binding installed.
  • Test-first and cross-language, for every binding language, not just Python (T-49) where it happened to land that way — see D-124. Test-first: the failing test for a given wrapper/ surface is written before that wrapper’s code, same as this project’s root “test-first, always” rule already requires for the Rust core — T-161’s own step 1 is the pattern every later binding’s step 6 follows, not a one-off. Cross-language: every binding’s category-1 correctness tests load the same shared vector files under crates/dstu-core/tests/vectors/ (already stated above) — two languages passing against one shared vector file is what makes them comparable, not a separate suite that runs one language’s output against another’s.
  • Accessible examples for a working programmer, not API reference restated: real recipes (“encrypt a file,” “hash a string,” “sign/verify a message”), comment-light, in an examples/ directory.
  • The same provisional-status banner the root README/crate docs already carry (T-112) — a binding that omits “Kalyna modes not primary-text-confirmed / Strumok vectors UAPKI-attributed” would be less honest than the Rust crate it wraps.
  • A cargo xtask subcommand, not a one-off shell/PowerShell script (D-12 — xtask is the single cross-platform QA entry point) — wired into CI the same way every other target already is.
  • Build/test only, never publish, until publishing that specific registry is separately, explicitly requested — the same gating T-17 already applies to crates.io (still not requested as of this document). PyPI/npm/Maven Central/NuGet/RubyGems/Packagist are five more instances of the same class of decision, not a bundle to authorize once.

Project structure

New top-level bindings/ directory, sibling to crates/:

bindings/
  python/       # PyO3 crate + maturin config, pytest suite, examples/
  capi/         # C ABI crate (cdylib+staticlib), generated header, C smoke test, examples/
  dotnet/       # P/Invoke wrapper over capi, xUnit suite, examples/
  java/         # JNI (jni crate or over capi, per Phase 4 spike), JUnit suite, examples/
  nodejs/       # napi-rs crate, node:test suite, examples/
  cpp/          # thin C++ header-only RAII wrapper over capi, a small test, examples/
  php/          # ext-php-rs extension (or FFI-extension over capi), PHPUnit suite, examples/
  ruby/         # magnus/rb-sys crate, RSpec/Minitest suite, examples/

Each binding directory carries its own README.md (provisional-status banner + quickstart), its own test suite in the language’s native layout, and its own examples/ directory — see the checklist above for what each of those must contain.

Phased roadmap

Full phase-by-phase task breakdown, with commit points, lives in docs/TASKS.md’s “Phase 3 — Language bindings” section (T-158 onward) — this document states the reasoning and order; that document tracks live status so it doesn’t drift from this analysis. Summary:

PhaseDeliverableDepends on
0This document + tracking + naming check (done this session)
1Python binding (the template)Phase 0
2C ABI cratePhase 1’s pipeline lessons
3.NET bindingPhase 2
4Java binding (spike first)Phase 2
5Node.js bindingPhase 1’s pipeline lessons
6C++ bindingPhase 2
7Publishing to each registry — owner-gated, one explicit ask per registryPhases 1-6
8PHP bindingPhase 2
9Ruby bindingPhase 1’s pipeline lessons
10GitHub-facing docs + gh-pages site refreshPhases 1-9
11Go binding (T-163, added 2026-08-02)Phase 2 (C ABI - no direct-Rust-binding toolchain for Go has PyO3/napi-rs/magnus’s maturity)

Phase numbers above are dependency labels, not the current build sequence — D-121/D-122/D-123 reordered the actual sequence (Node/Ruby/PHP before the C ABI group; Go added, needing the C ABI, built ahead of C++ specifically; Dart deferred). docs/TASKS.md’s “Build order revised 2026-08-02” line is the current authoritative sequence; this table stays as originally written since the dependency relationships it states (what needs what) are still accurate, only the order changed.

Cross-session execution plan

Requested 2026-08-02: a granular, checkable, per-task step list that survives a memory clear or a new session — this section is the one to update as work lands, and the one to read first when resuming. Update the resume line below every time a step is checked off; a stale resume line is worse than no resume line, since it actively misdirects the next session.

*Resume point: T-161 done (2026-08-02). T-49 (Python) done in full 2026-08-02 - see D-120. T-50 (Node.js) done in full 2026-08-02 - see D-125 through D-132 (step 6 done before step 5, a tooling-forced reorder, D-129 explains why; D-130 corrects D-125’s toolchain-pin approach). T-160 (Ruby) done in full 2026-08-02 - see D-133 (own Ruby+MSYS2-clang toolchain install, several real rb_sys/bindgen gotchas), D-134 (full crypto_ surface), D-135 (SecretStreamWriter/Reader, Zlib::GzipWriter/Reader-modeled), D-136 (advisor-review fixes to steps 2-3, then step 4’s precompiled native gem - a source gem cannot install standalone at all, the path-dependency finding), D-137 (cargo xtask ruby + bindings-ruby.yml, rubocop wired in), D-138 (58-example RSpec suite, cross-language vector loading, real uacrypt interop), D-139 (examples/ + README.md), D-140/ D-141 (three real CI round-trips to get bindings-ruby.yml actually green - ridk not on the hosted runner’s PATH, Gemfile.lock missing non-Windows platforms, and the root rust-toolchain.toml silently overriding rustup default on Windows - confirmed green on real CI, run id 30759971107, all four jobs success). T-159 (PHP) done in full 2026-08-02 too - see D-142 through D-147 (flat dstu_core_* naming modeled on ext-sodium, a plain PHP Writer/Reader over stream_filter_register rejected for step 3, a real xtask-level RUSTUP_TOOLCHAIN inheritance bug found and fixed (D-146), and D-147’s own two CI round-trips - a macOS -undefined dynamic_lookup linker gotcha, a cross-OS cargo-deny license-allow-list gap, and a Windows pwsh-vs-bash POSIX-path mismatch - confirmed green on real CI, run id 30765006443, all four jobs success). T-158 (C ABI crate) done in full 2026-08-03 - see D-148 (pre-implementation design forks: symbol prefix, cbindgen-via-xtask, output-buffer convention, unconditional std dependency, unsafe-boundary hygiene, rlib crate-type) and D-149 (the implementation: cbindgen.toml, crates/dstu-core-capi’s full crypto_* wrap, the C test harness, examples, README, cargo xtask capi plus a new capi job in rust.yml - not yet confirmed on real CI, only verified locally on this Windows-GNU dev machine, same caveat every prior binding’s own first-pass session carried). T-52 (.NET) done in full 2026-08-03 - see D-152 (P/Invoke [LibraryImport] bool-marshalling finding, SafeHandle handles, SecretStreamEncryptStream/DecryptStream’s Complete()-not-Dispose() finalization split, NuGet packaging + fresh-install check, then the Pi ARM64 re-check - step 10, all green first try, no bug found) - T-52 is now done in full, all ten standard steps. T-51 (Java) done in full 2026-08-03, all ten standard steps - see D-153 (step-0 spike chose the jni crate direct-Rust binding over JNI-over-capi; full crypto_* surface, SecretStreamEncryptor/Decryptor, 56 JUnit tests including real uacrypt interop, cargo xtask java + CI, examples/README; JDK build/test baseline 17, published bytecode target 8; step 10’s Pi re-check found one real bug - Debian’s apt-packaged Maven defaults to an old maven-compiler-plugin that silently ignores maven.compiler.release, fixed by pinning the plugin version explicitly). T-163 (Go) done in full 2026-08-03, all ten standard steps - see D-155 (step-0: hand-written cgo over c-for-go, decided on inspection rather than a full spike, since T-158’s own C ABI surface is already stable; a real selftest-only link spike found two genuine static-linking gaps on Windows-GNU - -ldstu_core_capi alone links dynamically unless -Wl,-Bstatic/-Bdynamic bracket it, and the Rust staticlib transitively needs -lws2_32 -luserenv -lntdll even though dstu-core-capi itself never touches networking; full crypto_* surface, CryptoError/ArgumentError/InternalError split, SecretStreamEncryptWriter/DecryptReader (io.Writer/io.Reader-shaped, Complete()-not- Close() finalization split same as .NET’s), cargo xtask go + bindings-go.yml CI (Windows leg forces the GNU-hosted Rust toolchain since cgo can’t link MSVC output - unconfirmed on real CI as of this writing), examples/README; step 10’s Pi re-check found the Windows-only LDFLAGS didn’t work unmodified on Linux - fixed with cgo’s own per-GOOS #cgo pragma syntax, all tests then green on real aarch64). T-53 (C++) done in full 2026-08-03, all ten standard steps - see D-158 (four step-0 forks: Finish()-not-destructor Final emission, std::ostream&/std::istream&, prebuilt-lib CMake packaging with no FetchContent for the Rust side, hand-rolled CHECK-macro test harness mirroring c-tests/test_capi.c); header-only C++17 RAII wrapper (unique_ptr-backed move-only handles) over crates/dstu-core-capi’s cdylib (not the staticlib Go links - matches the C test harness’s own existing choice), full crypto_* surface, exception-based errors, real bidirectional uacrypt.exe interop in the test suite, cargo xtask cpp + bindings-cpp.yml CI (no Windows GNU-forcing needed, branches on target_env the same way capi() already does), five examples + README; step 10’s Pi re-check found no bug this time (unlike D-151’s c_char/i8 finding in the C ABI crate) - libdstu_core_capi.so linked correctly, Kupyna-256 digest byte-identical to the x86-64 dev machine. Pushed and confirmed green on real CI, run id 30839873166, all three bindings-cpp.yml jobs (ubuntu-latest/GCC, macos-latest/Clang, windows-latest/MSVC) success — MSVC/Clang were never exercised locally on this dev machine (no cl.exe on PATH, no local macOS box), so this CI run is their only confirmation, checked via gh run view per CLAUDE.md’s own rule, not assumed from the push alone. Every planned binding (T-49/T-50/T-160/T-159/T-158/T-52/T-51/T-163/T-53) is now done in full. Next: T-162 (docs, last).

The standard binding steps

Every binding task (T-49/T-50/T-51/T-52/T-53/T-158/T-159/T-160) follows this same ten-step template unless its own entry below says otherwise — written once here rather than repeated ten times, per this project’s own “three similar lines beat a premature abstraction, but don’t duplicate a real invariant” instinct. (Step 10 was added 2026-08-03 — T-49/T-50/T-158/T-159/T-160 predate it and weren’t retroactively re-run for it at the time; D-151’s own pass covered all of them retroactively the same day it was added, see that entry.)

  1. Scaffold the binding crate/project, wired into the Cargo workspace where applicable.
  2. Wrap the full crypto_* surface, zero-config (D-116), including a selftest() wrapper around T-161.
  3. Wrap crypto_secretstream in the language’s idiomatic stream/pipe primitive (D-118). Two pitfalls found by advisor review while building T-49’s own wrapper — check both again for every later binding, not just Python (see T-49 step 3’s own entry below for the concrete Python bugs and fixes): - The language’s own “always runs, even on error” resource-cleanup hook must NOT finalize (emit the Final chunk) on the error/exception path. Python’s __exit__(exc_type, ...) was the concrete case (T-49) — it originally called close() unconditionally, so a write loop that raised partway still produced a stream with a Final chunk, and a reader saw a complete-looking file instead of failing closed (violates D-65’s “no partial output treated as valid on failure”, the same property uacrypt encrypt’s own temp-file-then-rename gets for free). Every language’s equivalent hook has the same shape and needs the same check: C#’s using/IDisposable.Dispose(), Node’s stream.Transform _flush/'error' vs. 'end' event, Java’s try-with-resources close(), C++ RAII destructors (which can’t even see whether unwinding is due to an exception without extra machinery — decide the mechanism deliberately, don’t assume the default is correct). - The wire-format reader must validate untrusted length-prefixed fields itself, not just copy the encoder’s happy path. Python’s decoder read the wire chunk_len field (attacker-controlled, read before any tag verification) and used it directly to size a read, with no upper bound — for a file this just hits EOF, but the language’s own file-like abstraction may also have to accept a socket/pipe, where an oversized declared length means accumulating gigabytes before ever failing. Also missed: rejecting trailing bytes after the Final chunk (silently ignored instead of erroring). Both are checks uacrypt decrypt already has (CliError::SecretstreamChunkTooLarge/ CliError::SecretstreamTrailingData, crates/uacrypt/src/lib.rs) — port them explicitly into every language’s own reader, they don’t come for free from the wire format matching.
  4. Prebuilt-artifact packaging for the target platform(s) (D-116) — build/local-install only, no registry publish.
  5. cargo xtask subcommand + CI wiring (D-12).
  6. Local test suite: official vectors through the binding’s own API (category 1), rejection (category 2), misuse (category 3) — D-64/D-65 plus this session’s official-vectors requirement.
  7. examples/ + README.md with the provisional-status banner (T-112).
  8. Doc-map sweep (README.md/dstu-crypto-project.md/release-readiness.md/ user-journey-gaps.md/cross-language-style-guide.md) + mark the task done in docs/TASKS.md.
  9. Commit — each numbered step above is its own commit, not one large drop.
  10. Added 2026-08-03, D-151 — cross-arch smoke check on the Raspberry Pi rig (real aarch64 Linux, access/re-sync details in .claude.local.md, not here): cargo xtask <binding> run there after installing whatever this binding’s own toolchain needs (see D-151/docs/TASKS.md T-35’s entry for the concrete per-language install commands already worked out — Node/Ruby/ PHP/Python/cbindgen). Same “no CPU-family lock-in” reasoning docs/TASKS.md T-35 already applies to the core crate, extended to cover a binding’s own FFI-boundary code too — D-151 found a real bug this way (a hardcoded i8 test buffer that should have been c_char, compiling fine on every x86-64 platform but not on ARM Linux’s unsigned-by-default char). Run bindings sequentially on the Pi, not concurrently — two at once race on ~/.rustup’s shared component-download cache (D-151’s own process-lesson note).

T-161 — dstu_core::selftest (first; nothing below can start without it)

No binding exists yet at this point, so the standard template above doesn’t apply — this is real Rust-core work, confirmed as a genuine gap (see docs/TASKS.md T-161’s own note).

Done 2026-08-02.

  1. Test-first: write the test asserting selftest::run() reports success, before the module exists.
  2. New Cargo feature (selftest), off by default in the bare crate.
  3. Embed the official vectors (build-time include from crates/dstu-core/tests/vectors/*.json, not hand-copied).
  4. Implement run()scope note: one vector per primitive (Kalyna, Kupyna, Strumok, DSTU 4145), not one per every hazmat mode/crypto_* wrapper — a fast spot check of the underlying algorithm each of those builds on, not a re-run of the full tests/vectors/ corpus (the module’s own doc comment says this explicitly, so a future reader doesn’t assume broader coverage than exists). The report names which primitive(s) failed, if any.
  5. Verify: cargo test --features selftest (workspace-default run unaffected), cargo clippy --features selftest --all-targets -- -D warnings clean for the new files, cargo fmt --check clean, no_std/no_std+alloc/default builds all still succeed with the feature absent.
  6. Mark T-161 done in docs/TASKS.md, note in D-117 that it landed.
  7. Commit.

T-49 — Python (the template every later task assumes)

Standard steps above, with:

  • Step 1: bindings/python/, PyO3 + maturin. Corrected 2026-08-02, see D-119: its own [workspace] table, a path dependency on dstu-core, not added to the root Cargo.toml’s members - two existing CI jobs (cargo +nightly miri test --workspace, the MSRV-pinned --workspace build) would otherwise silently start covering a PyO3 cdylib neither job is equipped for. Same shape applies to T-50/T-160 (Node/Ruby, also direct Rust bindings) - T-158 (C ABI) is unaffected and stays a real workspace member, see D-119 for why the two cases differ. Done 2026-08-02: dstu_core_py crate (cdylib, pyo3 = "0.26", extension-module feature), mixed maturin layout (python/dstu_core/__init__.py pure-Python package wrapping the compiled _dstu_core extension). Wraps only selftest() so far, as this scaffold’s own pipeline proof - the full crypto_* surface is step 2, not yet done. Verified end-to-end, not just “compiles”: cargo build/clippy --all-targets -- -D warnings/fmt --check all clean; maturin develop (in a .venv, real Python 3.12.10 resolved via PYO3_PYTHON - see .claude.local.md, python/python3 on PATH are broken Store stubs on this machine) builds and installs the wheel; python -c "import dstu_core; dstu_core.selftest()" runs the real Rust self-check and returns cleanly. Confirmed the root workspace is unaffected: cargo build --workspace from the repo root still only sees crates/dstu-core/crates/uacrypt (this is the concrete case D-119 was written to prevent - cargo init inside bindings/python had in fact auto-added itself to the root Cargo.toml’s members before this was caught and reverted). Follow-up fix same day: pyo3 bumped "0.26""0.29" (the version actually resolved, caught in self-review) plus a PYO3_PYTHON build-prerequisite note added to bindings/python/README.md.
  • Step 2: Done 2026-08-02. One Rust module per dstu_core::crypto_* module - secretbox/secretstream/auth/kdf/generichash/stream/sign/pwhash/randombytes - plus pwhash turned on in bindings/python/Cargo.toml (it’s std-gated only, no reason to withhold it from a binding whose whole point is a full-surface wheel). Keys/ciphertexts/tags cross the FFI boundary as plain Python bytes, not an opaque handle type - SecretKey’s Zeroize-on-drop guarantee can’t reach a bytes object regardless of wrapper shape, so an opaque type would buy nothing here (PyNaCl’s own libsodium bindings make the same call). A single DstuError exception class covers every crypto-operation failure (tag mismatch, truncation, CSPRNG failure); the stdlib ValueError covers caller-input mistakes a fixed-size Rust array forecloses (wrong-length key/context/etc.) - two different failure classes, not one exception type doing both jobs. crypto_secretstream’s PushState/PullState are wrapped as thin #[pyclass]es mirroring the Rust API 1:1 (tag as a plain int, SECRETSTREAM_TAG_* module constants) - the idiomatic file-like wrapper is deliberately step 3, not built here. crypto_generichash’s streaming Kupyna{256,512}Hasher are #[pyclass]es holding Option<Hasher>, .take()n on finalize() since the wrapped Rust finalize(self) consumes ownership - a second finalize() call raises ValueError rather than panicking. Verified end-to-end via maturin develop + a real Python smoke script exercising every wrapped function, including tamper rejection (secretbox/auth/secretstream), wrong-message/wrong-key signature rejection, and a wrong-length-key ValueError - not just “it compiles.” cargo build/ clippy --all-targets -- -D warnings/fmt --check all clean; root cargo build --workspace reconfirmed unaffected.
  • Step 3: Done 2026-08-02. SecretStreamEncryptor/SecretStreamDecryptor (bindings/python/python/dstu_core/secretstream.py) - pure Python, built on step 2’s SecretStreamPushState/PullState rather than new Rust glue (native-language idiom is exactly what D-118 asks for, and file I/O against arbitrary Python file-like objects is more natural to write directly in Python than via PyO3 callbacks). write()/iterate hide chunk/tag/header bookkeeping entirely. Wire format matches uacrypt encrypt/decrypt exactly (8 KiB chunks, tag || len_u32_le || ciphertext || auth_tag records after a 32-byte header) - a deliberate choice, not required by D-118 itself, verified with a real interop test in both directions against the built uacrypt binary (not just self-consistency): a file SecretStreamEncryptor wrote round-tripped through uacrypt decrypt, and a file uacrypt encrypt wrote round-tripped through SecretStreamDecryptor. Also verified: exact-chunk-boundary plaintext sizes (e.g. exactly 2×8192 bytes) produce the identical byte layout to the Rust CLI’s own one-chunk-ahead buffering - the last full chunk is tagged Final directly, not followed by a spurious empty Final record (a real bug caught and fixed during this step, not assumed correct); tamper and truncation both raise DstuError. ruff check --fix/ruff format --check clean (installed into the .venv for this check - not yet wired into xtask/CI, that’s step 5).
  • Step 4: Windows wheel done locally 2026-08-02 (this machine is Windows-only - manylinux/ macOS builds genuinely need CI, not a local shortfall; deferred to step 5, reusing .github/workflows/release.yml’s existing matrix.os: [ubuntu-latest, macos-latest, windows-latest]/tag-trigger/artifact-upload conventions rather than inventing a parallel scheme). maturin build --release --out dist produces dstu_core-0.1.0-cp39-abi3-win_amd64.whl (one wheel for all supported CPython versions - see step 1’s abi3-py39 note); installed into a fresh venv via pip install (not the editable .venv every other check in this file used) and re-run against the full smoke suite (selftest, secretbox, the secretstream file-like pipeline, sign) - a materially different check than maturin develop, since it proves secretstream.py (added in step 3, after the previous packaging check) actually ships inside the wheel rather than only ever having been exercised through the source tree. manylinux/macOS wheels are folded into step 5 below, not a separate step - they need CI, not a local shortfall.
  • Step 5: Done 2026-08-02, see D-120. Two distinct CI pieces, not one (advisor review): (1) .github/workflows/bindings-python.yml, own job (D-119) - test (matrix ubuntu/macos/windows: fmt-check ubuntu-only per the autocrlf false-positive rust.yml’s own fmt job already avoids the same way, clippy, build uacrypt first from the repo root so the pytest interop test can’t silently skip, maturin build+pip install --find-links rather than maturin develop since develop needs a virtualenv a bare actions/setup-python interpreter isn’t, then pytest with an explicit grep-for-SKIPPED failure gate, then ruff), wheel-preview (the real PyO3/maturin-action@v1/manylinux: auto recipe, run on every push so a broken recipe is caught immediately - confirmed on real CI producing dstu_core-0.1.0-cp39-abi3-manylinux_2_17_x86_64. manylinux2014_x86_64.whl, the tag actually verified, not assumed), and supply-chain (cargo deny check/cargo audit against this workspace). (2) release.yml’s build-python-wheels job, same matrix/maturin-action recipe, added to publish-release’s needs (wheel-build failure blocks the release, a deliberate choice). cargo xtask python added (best-effort, D-12 posture: build uacrypt, fmt/clippy, maturin develop, pytest - verified locally, all 57 tests passing with the interop test actually running). Also closed in this pass: D-119’s own recorded consequence that root cargo deny/audit didn’t reach bindings/python’s dependency tree - turned out cargo-deny already walks up and finds the root deny.toml with no second file needed, and running it for the first time caught a real wildcard-dependency bug (missing version = on the dstu-core path dependency, T-75/D-11’s exact failure mode), fixed in the same pass.
  • Step 6: Done 2026-08-02, out of order (before step 5, advisor review) - a CI job wired to an empty test directory passes vacuously, so writing the suite first gives step 5 something real to fail on. 57 tests across every module, D-64/D-65’s three categories - see the T-49 section above for the concrete shape (a real Kupyna-256 vector, live uacrypt CLI interop, the two rejection gaps an earlier advisor pass caught). [project.optional-dependencies] dev group pins maturin/pytest/ruff to the versions verified this session.
  • Step 7: Done 2026-08-02. examples/ (secretbox.py, secretstream_file.py, sign.py, password_hashing.py, misc.py for auth/kdf/generichash/stream/randombytes) - each run against the real built extension before committing, not just written from the API surface. README.md rewritten from its step-1 “scaffold only” state to document the full surface with a module-by-example table; provisional-status banner kept, reworded to match. Wiring ruff into a real gate for the first time (step 5) surfaced two real PYI034 findings in secretstream.py’s __enter__ methods, fixed with an inline noqa (this binding’s requires-python floor is 3.9, typing.Self needs 3.11+, no typing_extensions dependency wanted for a pre-1.0 zero-dependency binding).
  • Step 8: Done 2026-08-02, this entry. Doc-map sweep: README.md (root repo-tree line was still “planned, not yet built”), docs/dstu-crypto-project.md, docs/release-readiness.md updated; docs/user-journey-gaps.md/docs/cross-language-style-guide.md checked, no T-49 references existed to update. T-49 marked done in docs/TASKS.md, D-120 added.
  • Step 9: each step above landed as its own commit (see git log for the exact sequence) - no large single drop.

T-158 — C ABI crate (foundation for C++/.NET, maybe Java)

Not a language binding itself — no idiomatic-language step 2/3 the way the others have; steps 1/4/5/6/7/8/9 of the standard template, renumbered for what this crate actually needs:

  1. Scaffold crates/dstu-core-capi (cdylib+staticlib+rlib) — opaque handles, explicit error codes, catch_unwind at every boundary call, zeroize-on-free. Verified the existing 8-combination feature matrix still passes with this new workspace member present (D-148/ D-149).
  2. cbindgen-generated header (include/dstu_core.h), including a dstu_selftest() export (T-161). usize_is_size_t = true in cbindgen.toml so generated signatures read size_t, matching the spec’s own C convention, rather than cbindgen’s default uintptr_t.
  3. xtask/CI wiring — cargo xtask capi (header regen+diff, C harness, examples) and a new capi job in rust.yml (matrix ubuntu/macos/windows; not yet confirmed on real CI, only verified locally on this Windows-GNU dev machine, D-149).
  4. Prebuilt dynamic/static libs per platform (D-116) — local build only so far (this Windows-GNU machine’s own target/release/{dstu_core_capi.dll,libdstu_core_capi.dll.a, libdstu_core_capi.a}); cross-OS release.yml packaging deferred, see D-149.
  5. A small C test harness (c-tests/test_capi.c): correctness, rejection, misuse per D-64/D-65, run against the just-built cdylib via cargo xtask capi.
  6. examples/ (secretbox.c, secretstream_file.c, sign.c, misc.c) + README.md provisional-status banner — each example actually run against the real built library, not just written from the API surface.
  7. Doc-map sweep + mark T-158 done — this entry, D-149.
  8. Commit per step (see git log for the exact sequence).

T-52 — .NET

Done in full 2026-08-03 — see D-152. No Cargo workspace of its own at all (unique among the bindings so far) - bindings/dotnet/DstuCore is pure C#, P/Invoking T-158’s already-built C ABI.

  • Step 1: Done. bindings/dotnet/DstuCore (net8.0 class library) + Directory.Build.props (copies whichever platform’s dstu_core_capi.{dll,so,dylib} exists under the repo’s target/release/ into every project’s own build output, so dotnet build/test/run need no manual copy step). Native/NativeMethods.cs uses [LibraryImport] (source-generated interop), not classic DllImport — its marshaller requires an explicit [MarshalAs(UnmanagedType.U1)] on every bool-returning export or the build fails to compile, catching at compile time what would otherwise be C#’s silently-wrong default 4-byte BOOL marshalling against Rust’s 1-byte bool (dstu_verify/dstu_verify_digest/ dstu_pwhash_verify_password/dstu_secretstream_{push,pull}_is_finalized — a wrong true out of dstu_verify specifically would be a silent signature-verification bypass, the .NET analogue of D-151’s ARM c_char/i8 finding, found by advisor review before implementation rather than after a failing test). Every opaque dstu_* handle is a SafeHandle subclass (Native/NativeHandles.cs), not a bare IntPtr — deterministic release + protection against premature finalization during a call, this project’s IDisposable/using idiom applied to a native handle.
  • Step 2: Done. Full crypto_* surface wrapped — AuthKey, KdfMasterKey, GenericHash/ Kupyna256Hasher/Kupyna512Hasher, SecretboxKey, SigningKey/VerifyingKey, StreamCipherKey (named to avoid colliding with System.IO.Stream), Pwhash, RandomBytes, Selftest. DstuException (crypto-operation/data-integrity failure) vs. ArgumentException (caller-input mistake) mirrors bindings/python’s own DstuError/ValueError split (Native/NativeStatus.cs centralizes the mapping).
  • Step 3: Done. SecretStreamEncryptStream/SecretStreamDecryptStream (Stream-derived, matching CryptoStream/GZipStream’s own shape per this document’s own template text). Both D-118 pitfalls apply, with one deliberate deviation from CryptoStream’s own close-flushes convention: Dispose() never emits a Final chunk at all — C#’s Dispose() has no parameter telling it whether it’s unwinding from an exception (unlike Python’s __exit__(exc_type, ...)), so finalization is an explicit, always-required Complete() call on the success path instead of a conditional one. The reader bounds the untrusted wire chunkLen field against DstuConstants.SecretstreamChunkBytes and rejects trailing bytes after Final, same as every other binding.
  • Step 4: Done. dotnet pack produces runtimes/{rid}/native/ for the build machine’s own RID (win-x64 here; cross-OS RIDs deferred to a release.yml job, same split T-158’s own step 4 took). Verified with a real fresh-install check: packed, installed from a local NuGet feed into an unrelated temp console project, Selftest.Run() + a SecretboxKey round trip both ran against the installed package.
  • Step 5: Done. cargo xtask dotnet (dotnet format --verify-no-changes + dotnet testbuild()/test()/clippy()/fmt() already cover this binding’s one Rust-side dependency, dstu-core-capi, for free since it’s a real workspace member) + bindings-dotnet.yml (ubuntu/macos/windows matrix).
  • Step 6: Done. 56 xUnit tests (DstuCore.Tests/) mirroring bindings/python/tests file-for- file — Kupyna-256 correctness against the real shared JSON vector, DSTU 4145 correctness via Selftest.Run() (matching bindings/python/tests/test_sign.py’s own precedent — the Annex B.1 vector is exercised there, not re-derived per binding), real bidirectional uacrypt interop for secretstream, D-64/D-65’s three categories throughout.
  • Step 7: Done. examples/ (one console project, dotnet run -- <name> dispatch — secretbox/secretstream-file/sign/password-hashing/misc, mirroring bindings/python/examples file-for-file) + README.md with the provisional-status banner.
  • Step 10: Done 2026-08-03. Real aarch64 Linux (the Raspberry Pi rig) had no .NET SDK installed at all before this - dotnet-install.sh --channel 8.0 (Microsoft’s official install script; Debian isn’t one of the OSes packages.microsoft.com’s apt feed officially supports, unlike Ubuntu) got a real linux-arm64 SDK working there for the first time. All 56 tests passed on the first run, no ARM-portability bug found this time (unlike D-151’s c_char/i8 finding in the C ABI crate’s own test) - genuine evidence the [LibraryImport]/SafeHandle/nuint marshalling choices in D-152 are actually architecture-portable, not just working by x86-64 coincidence.

T-51 — Java

Standard steps, plus an upfront spike before step 1 (see docs/bindings-strategy.md Fork 1):

  • Step 0: done 2026-08-03, see docs/DECISIONS.md D-153. Built two real, runnable prototypes (not reasoned from memory) - Spike A (jni = "0.21" crate, direct Rust binding against dstu_core, no C ABI involved) vs. Spike B (hand-written C JNI shim over T-158’s already-built dstu-core-capi). Both worked on the first run; chosen: Spike A - Java joins Python/Node/ Ruby/PHP’s direct-binding group, not .NET/C++/Go’s C-ABI group. Spike B would have added a third language (C) to the binding and doubled the packaged native surface per platform; Spike A avoids the C ABI’s caller-allocated-out-buffer protocol the same way Python/Node/Ruby already do. Panama (JEP 454) named and rejected (JDK 22+ baseline too new for this audience), not left unmentioned. jni pinned to 0.21, not 0.22 (a real breaking JNIEnv/EnvUnowned API change, confirmed by actually trying the bump, not assumed). JDK baseline: build/test on 17 (matches the Pi’s Debian 12 default), but the published artifact’s bytecode target is <maven.compiler.release>8</maven.compiler.release> - Java 8 still has real enterprise/PKI- adjacent footprint (owner-requested correction), verified empirically by cross-compiling Spike A with --release 8 from the JDK 17 install and running the resulting class on a real local JDK 8 JVM, all three test paths (selftest, seal/open round trip, wrong-key exception) unchanged. CI must matrix JDK 8 and 17 for the test suite (step 5), not just build once on 17.
  • Step 1: direct-Rust binding via the jni crate (own [workspace], D-119), per the spike above - not JNI-over-capi.
  • Step 3: an InputStream/OutputStream pair; D-118’s Java pitfall carries over from T-52’s own resolution unchanged (try-with-resources close() can’t see whether the block threw, same structural limitation as C#’s Dispose() - explicit complete(), not auto-finalize-on-close).
  • Step 4: a native library bundled per OS/arch classifier (or one fat JAR).
  • Step 5: cargo xtask java + CI, matrix at least JDK 8 and 17 (per step 0’s finding above).
  • Step 6: JUnit, run under both JDK 8 and 17 in CI.

T-50 — Node.js

Standard steps:

  • Step 1: Done 2026-08-02, see D-125/D-130. bindings/nodejs/, napi-rs, own [workspace] table per D-119 (not a root workspace member). Wraps only selfTest() so far, matching T-49 step 1’s own split. napi-build = 2.0.0 pinned in Cargo.lock (a real MSRV constraint, D-125). The MSVC toolchain this machine’s build needs is a machine-local rustup override, not a committed file (D-130 corrects D-125’s original committed-rust-toolchain.toml approach, which would have broken Linux/macOS CI runners) - see .claude.local.md for the exact command.
  • Step 2: Done 2026-08-02, see D-126. Full crypto_* surface wrapped - secretbox, sign, pwhash, generichash (one-shot + incremental Kupyna{256,512}Hasher classes), auth, kdf, stream, randombytes, plus crypto_secretstream’s raw push/pull (idiomatic stream.Transform still deferred to step 3, matching Python’s own step 2/3 split). Every byte parameter/return uses napi::bindgen_prelude::Buffer (maps to a real JS Buffer), not Vec<u8> (which napi-rs maps to a plain JS number array, wrong for binary data - confirmed by reading napi’s own Vec<T>/Buffer ToNapiValue/FromNapiValue impls, not assumed). Every function has an explicit js_name for camelCase (napi-derive does not auto-convert casing from the Rust identifier, unlike PyO3’s implicit snake_case passthrough that Python’s own snake_case-native convention didn’t need to override). Multi-value returns (secretstream’s push/pull) use a #[napi(object)] struct with named, camelCase fields (SecretStreamPushResult/SecretStreamPullResult) rather than a tuple - napi-rs has no tuple ToNapiValue impl at all, and a named-field result object is the more idiomatic JS shape anyway (matches this project’s cross-language style guide principle 2, name communicates intent).
  • Step 3: Done 2026-08-02, see D-127. SecretStreamEncryptor/SecretStreamDecryptor, a stream.Transform pair in pure hand-written JS (bindings/nodejs/js/secretstream.js) on top of step 2’s raw SecretStreamPushState/PullState, mirroring bindings/python/python/dstu_core/secretstream.py’s design and wire format exactly (same 8 KiB SECRETSTREAM_CHUNK_BYTES, same tag(1) || len_u32_le(4) || ciphertext || authTag(16) framing, interoperable with uacrypt encrypt/decrypt in both directions - verified against the real uacrypt binary, not just self-consistently). Generated napi output relocated to bindings/nodejs/native/ (via napi build native) so the hand-written js/index.js entry point can live at the package root without colliding with the regenerated files. Both D-118 pitfalls re-checked for this port specifically (D-127 has the detail): _flush (not _destroy) emits the Final chunk, so an upstream error never produces a complete-looking truncated file; chunkLen is bounds-checked the moment it is parsed, and trailing bytes after Final are rejected both mid-stream and at _flush.
  • Step 4: Windows prebuilt artifact done locally 2026-08-02, see D-128 (this machine is Windows-only, same constraint Python’s own step 4 hit - Linux/macOS cross-builds genuinely need CI, deferred to step 5, not a local shortfall). package.json’s files field (js/, native/index.js, native/index.d.ts, native/*.node) makes npm pack bundle the native/ build output despite it being gitignored from source control - files overrides the .gitignore-based default for packing specifically, a real gotcha found and fixed here, not assumed to just work. Verified with a genuine fresh-install round trip (Python’s own step-4 bar): npm pack into a tarball, npm install <tarball> in an unrelated temp directory as a real dependency, then require('dstu-core') (not the source tree) and re-run the full smoke suite (selfTest, secretbox, the secretstream stream.Transform pair) against the installed package - proves the packaged artifact actually contains everything needed, not just the dev source tree.
  • Step 5: Done 2026-08-02, see D-131. cargo xtask nodejs (mirrors python() exactly) + .github/workflows/bindings-nodejs.yml (mirrors bindings-python.yml’s shape: test matrix ubuntu/macos/windows, supply-chain deny/audit). No MSVC-specific CI step needed anywhere - windows-latest is MSVC-host by default (D-130). Real gotcha hit and fixed: a bare Command::new("npm") fails to resolve on Windows the same way mvn already needed a .cmd special-case - command_for() extended accordingly.
  • Step 6: Done 2026-08-02, see D-129 — done before step 5 for this binding specifically, a tooling-forced reorder (node --test test/ errors on a nonexistent directory, unlike pytest’s vacuous-pass-on-empty-collection behavior Python’s own step 5-before-6 order relied on), not a preference change to the standard template. node:test, one file per crypto_* module, mirroring bindings/python/tests/*.py file-for-file. Found and fixed a real node:test-runner hang: SecretStreamEncryptor/Decryptor’s _transform/_flush callbacks were invoked synchronously, which Node’s own docs warn can make an error throw synchronously out of the triggering .write() instead of emitting 'error' the documented async way - fixed by deferring through process.nextTick, confirmed stable across three repeated full-suite runs.
  • Step 7: Done 2026-08-02, see D-132. examples/{secretbox,secretstream-file,sign, password-hashing,misc}.js (one-for-one with Python’s own five example files) and a fully rewritten README.md (T-50 step 1 never created one - a gap Python’s step 1 didn’t have).
  • Step 8: Done 2026-08-02. Swept README.md/dstu-crypto-project.md/release-readiness.md (stale “T-50 onward haven’t started” framing); user-journey-gaps.md/cross-language-style- guide.md checked, no T-50 references existed to update (same finding T-49’s own step 8 had). T-50 is now done in full - all nine standard steps - see docs/TASKS.md.
  • Node-only (D-118) — browser/WASM is explicitly deferred; don’t reinterpret this task as covering it.

T-53 — C++ (reordered 2026-08-02, D-123: now builds after T-163/Go)

Done in full 2026-08-03, all ten standard steps — see D-158. Standard steps, consuming T-158’s header:

  • Step 1: Done, see D-158. bindings/cpp/include/dstu/*.hpp, C++17, header-only. Move-only RAII wrapper classes over every opaque crates/dstu-core-capi handle via std::unique_ptr<T, void(*)(T*)> (a custom-deleter unique_ptr gives move semantics almost for free, avoided writing ~8 near-identical move-ctor/move-assign/destructor bodies by hand). Full crypto_* surface. Errors are exceptions (dstu::CryptoError/ArgumentError/InternalError, cross-language-style-guide.md principle 4), matching Python’s own choice from that table’s “exception or return code” row.
  • Step 3: Done, see D-158. std::ostream&/std::istream& (D-158 point 2) — never opened or closed by this wrapper (unlike Go/.NET’s own leaveOpen-flag closer-forwarding, unnecessary here since a C++ reference is never owning). SecretStreamEncryptor/Decryptor. The finalization pitfall (D-118) resolved by porting the Complete()-not-Dispose()/Close() split D-152 (.NET)/D-155 (Go) already chose: a destructor cannot reliably tell exception-unwind from normal scope exit without std::uncaught_exceptions() bookkeeping (fragile under nested exceptions besides), so the destructor only frees the native push state; emitting the Final chunk is a separate explicit Finish() call on the success path only. Reader hardening (chunk length bound, trailing-data rejection) ported from crates/uacrypt/src/lib.rs’s CliError::SecretstreamChunkTooLarge/SecretstreamTrailingData, cross-checked byte-for-byte against bindings/go/dstu/secretstream.go’s wire framing.
  • Step 4: Done, see D-158. Prebuilt lib alongside the header, no CMake FetchContent for the Rust side (no tooling equivalent of corrosion is already a project dependency). bindings/cpp/ CMakeLists.txt: an INTERFACE header-only target plus a SHARED IMPORTED target pointing at crates/dstu-core-capi’s already-built cdylib (DSTU_CORE_CAPI_DIR/DSTU_CORE_TARGET_DIR variables, defaulting to the sibling crate/target/release) — matches c-tests/test_capi.c’s own existing choice of linking the cdylib, not the staticlib bindings/go links (D-155’s -Wl,-Bstatic/-Bdynamic bracketing and transitive -lws2_32 -luserenv -lntdll needs don’t apply here, since the cdylib itself resolves those at its own link time).
  • Step 5: Done, see D-158. GCC verified locally on both this project’s own Windows-GNU dev-machine posture (MinGW Makefiles) and the aarch64 Pi (step 10); cl.exe isn’t on this dev machine’s PATH (confirmed by trying, not assumed) and no local macOS/Clang machine exists, so MSVC and Clang were confirmed the other way — pushed and checked via gh run view (CLAUDE.md’s own “never assume from a green badge” rule), run 30839873166, all three bindings-cpp.yml legs (ubuntu-latest/GCC, macos-latest/Clang, windows-latest/MSVC) green, MSVC’s leg 2m48s vs. ~40s for the other two (cl.exe’s own known slower cold-start, not a problem). cargo xtask cpp builds dstu-core-capi+uacrypt, then cmake configure+build+ctest — branches on target_env the same way xtask’s own capi()/capi_compile_msvc already do for the plain-C harness, so no Windows GNU-forcing is needed the way bindings-go.yml’s cgo requirement needed one (D-155); the MSVC branch (dstu_core_capi.dll.lib import lib) is confirmed by this same CI run, not just reasoned from capi’s own precedent.
  • Step 6: Done, see D-158. tests/test_dstu.cpp, a hand-rolled CHECK macro mirroring c-tests/test_capi.c’s own structure exactly (no Catch2/doctest/GoogleTest — C++ has no stdlib JSON either, so the single official Kupyna-256 vector is hand-transcribed the same way the C harness already does it, matching cross-language-style-guide.md’s “standard library over a third-party one” KISS principle). D-64/D-65’s three categories throughout, plus a real bidirectional uacrypt.exe interop test (std::system, with the documented Windows cmd.exe outer-quote-wrapping workaround for its “first token is quoted” parsing quirk) and an explicit property test for the D-118 no-finalize-on-error property (destroying an encryptor without calling Finish() leaves a stream a decryptor must fail closed on).
  • Step 7: Done. examples/{secretbox,secretstream_file,sign,password_hashing,misc}.cpp (one-for-one with the other bindings’ own five example files) + README.md with the provisional-status banner and a module-by-example table.
  • Step 8: Done, this entry. Doc-map sweep: docs/dstu-crypto-project.md/ docs/release-readiness.md/README.md’s own repo-tree listing updated; docs/user-journey- gaps.md checked, no T-53 references existed to update (same finding every earlier binding’s own step 8 had). T-53 marked done in docs/TASKS.md.
  • Step 9: each step above landed as its own commit, not one large drop.
  • Step 10: Done. Raspberry Pi ARM64 re-check - cargo xtask cpp green end-to-end on real aarch64 (cmake 3.25.1/g++ 12.2.0, both already present, no new install needed unlike Node/Ruby/ PHP/.NET’s own first Pi runs), including the real uacrypt↔C++ interop test over a plain POSIX sh (not Windows cmd.exe - RunCommand’s outer-quote wrapping is a no-op there, D-158’s own test file comment). Links libdstu_core_capi.so (confirmed via file, not assumed) - the non-Windows CMakeLists branch exercised for the first time on real hardware. Kupyna-256(“hello world”) verified byte-identical to the x86-64 dev machine’s own digest. No ARM-portability bug found this time (unlike D-151’s c_char/i8 finding in the C ABI crate itself), matching T-52/.NET’s own clean first pass rather than T-51/Java’s or T-163/Go’s own Pi-specific findings.

cargo xtask cpp passes end-to-end on both the x86-64 Windows dev machine (GCC/MinGW, all tests + all five examples green, real uacrypt.exe interop confirmed both directions) and the aarch64 Pi.

T-159 — PHP (reordered 2026-08-02, D-121: builds right after T-49/T-50/T-160, not deferred)

No longer consumes T-158. Original plan left ext-php-rs vs. FFI-over-the-C-ABI open; D-121 commits to ext-php-rs specifically so this binding is a direct Rust binding like Python/Node/Ruby and doesn’t wait on the C ABI crate at all.

Done in full 2026-08-02. Standard steps:

  • Step 1: Done, see D-142. bindings/php/, ext-php-rs, own [workspace] table (a direct Rust binding, same shape as Python/Node/Ruby - no ext/ split needed, ext-php-rs has no rb_sys-style cargo metadata quirk). PHP 8.3.33 installed by hand (winget’s own packages 404’d on a stale manifest patch version). Windows needs nightly Rust (abi_vectorcall) + the MSVC host (PHP’s own Windows builds are MSVC) + rust-lld (avoids an MSVC-linker-version mismatch) - a machine-local rustup override, matching Node’s D-130 pattern. ext-php-rs’s own Windows build script downloads a matching devel pack from windows.php.net automatically - no manual devel-pack management needed.
  • Step 2: Done, see D-142. Full crypto_* surface wrapped - flat dstu_core_*-prefixed global functions + a single DstuCoreException class, modeled directly on PHP’s own bundled ext-sodium extension (sodium_crypto_secretbox, SodiumException) rather than a namespace or static-method class. Binary<u8> (not String/Vec<u8>) for every crypto byte parameter/return - confirmed by reading ext-php-rs’s own source that PHP strings are raw byte buffers, not UTF-8-validated. Three real build-error findings: wrap_function!() needs its argument in the same module as the #[php_function] it names (fixed via a per-module register(ModuleBuilder) function); u8 doesn’t implement IntoConst (PHP has no unsigned int type); #[php_function]’s default rename splits a letter-to-digit boundary (kupyna256 -> kupyna_256), fixed with an explicit #[php(name = ...)].
  • Step 3: Done, see D-143. PHP’s own stream_filter_register/php_user_filter mechanism was investigated and rejected (no clean hook for a one-time header write before filtered bytes, and PHP’s own internal stream buffer doesn’t align with the fixed 8 KiB chunk boundary) - a plain PHP DstuCoreSecretStreamWriter/Reader (lib/DstuCoreSecretStream.php, implementing Iterator) over a resource instead, matching Python’s/Ruby’s own choice. Found a real ext-php-rs gap: a Rust-registered exception class with no #[php_impl] constructor cannot be new-ed from pure PHP - fixed with a dstu_core_throw_error() escape hatch reusing the same Rust-side PhpException::from_class construction path. Verified both directions against the real built uacrypt.exe, plus six rejection/misuse cases including the D-118 no-finalize-on-error property.
  • Step 4: Done, see D-144. No PECL/Composer publish attempted (Composer never manages native extensions at all; PECL needs its own account/manifest/review pipeline) - the honest deliverable is a release-profile compiled binary plus a documented php.ini extension= line, verified with a fresh-install-style check (only the compiled .dll copied to an unrelated directory, loaded via a full path).
  • Step 5: Done, see D-145/D-146. cargo xtask php + bindings-php.yml (shivammathur/ setup-php, re-deriving the Windows nightly+MSVC axis rather than copying bindings-ruby.yml’s GNU-vs-MSVC conditional). PHPUnit ships as a standalone PHAR, no Composer dependency added. Found and fixed a real xtask-level bug (D-146, not PHP-specific): run()’s child cargo invocations inherited RUSTUP_TOOLCHAIN from the outer cargo xtask process, silently overriding any binding’s own directory-scoped rustup override - almost certainly affects cargo xtask nodejs identically, not yet re-verified there.
  • Step 6: Done, see D-145. 58 PHPUnit tests across all 10 crypto_* modules, mirroring bindings/ruby/spec/*.rb/bindings/nodejs/test/*.test.js file-for-file - the real official Kupyna-256 vector (D-124), real bidirectional uacrypt.exe interop, D-64/D-65’s three categories throughout.
  • Step 7: Done. examples/{secretbox,secretstream-file,sign,password-hashing,misc}.php (one-for-one with the other bindings’ own five example files) + README.md with a module-by-example table and the honest packaging story.
  • Step 8: Done, this entry. Doc-map sweep: docs/dstu-crypto-project.md/ docs/release-readiness.md updated (stale “T-159 onward haven’t started” framing); docs/user-journey-gaps.md/docs/cross-language-style-guide.md checked, no T-159 references existed to update (same finding every earlier binding’s own step 8 had). T-159 marked done in docs/TASKS.md.
  • Step 9: each step above landed as its own commit - no large single drop.

Not yet confirmed on real CI - bindings-php.yml has not been pushed to origin/master yet (push needs separate explicit approval, same posture T-160’s own push had). cargo xtask php passes end-to-end on this dev machine (58/58 tests, fmt/clippy clean).

T-160 — Ruby (reordered 2026-08-02, D-121: builds right after T-50, no longer last)

Standard steps:

  • Step 1: Done 2026-08-02, see D-133. bindings/ruby/, magnus/rb_sys, own [workspace] split across two files (bindings/ruby/Cargo.toml as the workspace root with members = ["ext/dstu_core_rb"], the actual crate inside ext/dstu_core_rb/ with no [workspace] of its own) — rb_sys’s Cargo::Metadata shells out to a plain cargo metadata from the gem root, so a Cargo.toml has to exist there or Cargo walks up and finds the repo-root workspace instead (D-133’s concrete failure mode). Hand-authored, not generated via bundle gem --ext=rust — that generator hung indefinitely in this non-interactive shell even with every documented flag, root cause not fully isolated (likely a Windows-Ruby console-handle quirk), not worth debugging further given Python/Node were both hand-authored too. Ruby itself had to be installed on this machine first (DevKit variant, bundles a matching MSYS2/mingw-w64-ucrt toolchain) — see .claude.local.md. Three more real toolchain gotchas found and fixed (D-133 has full detail): rb-sys-env pinned to "0.1" to match the installed rb_sys gem’s Makefile convention; rb-sys added as an explicit direct dependency (not just transitive via magnus) so Cargo’s DEP_RUBY_* build-script propagation reaches this crate’s own build.rs; the MSYS2 ucrt64 clang package installed and LIBCLANG_PATH pointed at it, since this machine’s pre-existing standalone Windows LLVM parses Ruby’s mingw-targeted headers incorrectly. Wraps only self_test so far (Ruby’s native snake_case needs no per-function casing override, unlike Node’s js_name requirement — D-126). Verified via a full clean rebuild (not incremental) plus a real ruby -Ilib -e "require 'dstu_core'; DstuCore.self_test" smoke call against the live compiled build; cargo fmt --all -- --check/cargo clippy --all-targets -- -D warnings both clean.
  • Step 2: Done 2026-08-02, see D-134. Full crypto_* surface wrapped, flat DstuCore.secretbox_seal-style naming (idiomatic restructuring deferred to step 3, same posture as Python/Node). RString::to_bytes() needs magnus’s "bytes" feature enabled - the alternative, as_slice(), is unsafe; enabling the feature keeps this binding’s wrapper code free of unsafe entirely. No tuple IntoValue (same gap as Node’s napi-rs, D-126) - Ruby’s own idiom is a positionally-destructured Array, so secretstream’s push/pull build a two-element RArray rather than reaching for a named-struct workaround. method!’s trait bounds need Fn(&Ruby, RbSelf, Args...) order for a Ruby-taking instance method, incompatible with &self sugar - every instance method keeps plain &self and calls Ruby::get() internally instead, matching step 1’s self_test() pattern; only function!-registered constructors/ module functions take ruby: &Ruby as a literal first parameter. Verified via a 15-check smoke script against the live compiled .so (round-trip, tamper-rejection, wrong-length-key rejection, hasher double-finalize rejection, secretstream push/pull); cargo clippy --all-targets -- -D warnings clean.
  • Step 3: Done 2026-08-02, see D-135. SecretStreamWriter/SecretStreamReader (bindings/ruby/lib/dstu_core/secretstream.rb), pure Ruby on top of step 2’s raw SecretStreamPushState/PullState. Idiom researched, not assumed: modeled on stdlib’s own Zlib::GzipWriter/Zlib::GzipReader (same “wraps an arbitrary IO, transforms chunks transparently” shape). SecretStreamReader includes Enumerable. Both D-118 pitfalls re-checked: SecretStreamWriter.open deliberately avoids Ruby’s own ensure-based cleanup idiom (would finalize even on the error path) in favor of a plain last-statement close on the block’s normal-return path only; the reader bounds chunk_len and rejects trailing data after Final. Verified against the real uacrypt.exe bidirectionally, plus exact chunk-boundary sizing and the ensure-avoidance pitfall itself, all against the live compiled .so. rubocop deferred to step 5 (matching where Python’s own ruff landed), not introduced here.
  • Step 4: Done 2026-08-02, see D-136. rake native gem (this machine’s Windows/x64-mingw-ucrt platform only - Linux/macOS cross-compiled native gems need rake-compiler-dock/Docker, deferred to CI, same precedent Python/Node’s own step 4 set). Real finding: a source gem cannot install standalone at all - confirmed by installing into a fresh GEM_HOME and watching cargo fail to resolve the ext/dstu_core_rb/Cargo.toml path dependency on crates/dstu-core, which only exists inside this repo’s own tree. A precompiled, platform-tagged native gem (which rake-compiler/rb_sys already build via an auto-defined native task chain) ships the compiled .so directly instead, sidestepping the path dependency entirely. Verified via the same fresh-GEM_HOME install bar Python/Node’s own step 4 used: require "dstu_core", self_test, and a full SecretStreamWriter/Reader round-trip all pass against the installed gem. Same advisor pass also caught and fixed five real correctness gaps in steps 2/3 before they could ship (gemspec files glob, missing binmode, the binary-string encoding contract, is_finalizedfinalized?, ArgumentErrorIOError for write-after-close) - see D-136 for the full list.
  • Step 5: Done 2026-08-02, see D-137. cargo xtask ruby (mirrors python()/nodejs()) + .github/workflows/bindings-ruby.yml (mirrors the same shape: test matrix + supply-chain). rubocop (deferred from step 3) wired in here, matching where Python’s own ruff landed - 63 offenses on the first pass, settled in .rubocop.yml (double-quoted strings matching this project’s other languages, Layout/EndOfLine disabled for the Windows autocrlf false positive, Metrics/MethodLength raised slightly for the wire-format parsing methods) rather than reflowing to defaults. command_for() extended a third time (bundlebundle.bat on Windows, same gotcha as mvn/npm). CI’s Windows leg needs one binding-specific step no other language does: install a matching MSYS2 clang via ridk exec pacman and point LIBCLANG_PATH at it (D-133’s fix, codified for CI). cargo deny/cargo audit verified locally against this workspace’s real dependency tree.
  • Step 6: Done 2026-08-02, see D-138. 10 spec files, file-for-file mirroring Python/Node’s own test suites - 58 examples, D-64/D-65’s three categories. Confirmed empty bundle exec rspec passes vacuously (unlike Node’s node --test, D-129) - no tooling-forced reorder needed, unlike Node’s own step 6. generichash_spec.rb loads the same shared Kupyna-256 vector JSON the Rust tests use (the actual cross-language mechanism, D-124). secretstream_spec.rb’s real uacrypt interop uses if: metadata to run only when the binary exists, confirmed by counting examples in --format documentation output, not assumed; the uacrypt-missing case uses skip (visible in RSpec’s summary) rather than silently vanishing - cargo xtask ruby/CI always build uacrypt first, so this never actually triggers there. rubocop needed one spec-specific config addition (Metrics/BlockLength excluded for spec/**/*.rb, the standard shape for RSpec test files).
  • Step 7: Done 2026-08-02, see D-139. examples/{secretbox,secretstream_file,sign, password_hashing,misc}.rb, one-for-one with Python/Node, each run against the real compiled .so. README.md written from scratch (no README existed after step 1, same gap Node’s own step 1 had). One real fix found: require_relative "../lib/dstu_core" alone doesn’t reach lib/dstu_core.rb’s own internal non-relative require "dstu_core/dstu_core_rb" - every example adds lib/ to $LOAD_PATH explicitly first, matching how an installed gem’s own require "dstu_core" would resolve.

T-163 — Go (added 2026-08-02, D-122; builds alongside T-52/T-51, needs the C ABI)

Done in full 2026-08-03, steps 0-9 — see D-155. No incumbent DSTU library exists for Go, and it has a real DevSecOps/cloud-infra audience (same class of reasoning as Ruby’s own security/ops- tooling footprint) — but unlike Node/Ruby/PHP, no Go binding toolchain matches PyO3/napi-rs/magnus’s maturity, so this one goes through the C ABI crate (cgo over bindings/capi’s cbindgen- generated header) same as .NET/Java/C++. Builds after T-158 lands, alongside that group, not ahead of it. Reordered again 2026-08-02 (D-123): built ahead of T-53 (C++) specifically — the owner’s explicit preference, no further rationale recorded beyond that.

Standard steps, consuming T-158’s header:

  • Step 0: Done. Hand-written cgo, not c-for-go — decided on inspection, not a full spike (T-158’s ~50-function opaque-handle surface is already stable, and a generator would still need a hand-written idiomatic layer on top for the secretstream io.Writer/io.Reader wrapper anyway). A real selftest-only link spike (advisor-recommended vertical slice) found genuine static-linking gaps on Windows-GNU before the full surface was wrapped: -ldstu_core_capi alone links dynamically (GNU ld prefers the import lib over the static one when both exist) unless -Wl,-Bstatic/-Bdynamic bracket it, and even then three more system libraries (-lws2_32 -luserenv -lntdll) are needed for symbols the Rust standard library pulls in transitively (std::net, temp-dir/child-process-pipe code) despite dstu-core-capi itself never touching networking or process spawning.
  • Step 1: Done. bindings/go/dstu (package dstugo alone is a reserved word and can’t be a package identifier), wrapping every opaque handle with an explicit Close()no runtime.SetFinalizer backstop, a deliberate correction after advisor review found one would be a premature-free race, not a SafeHandle-equivalent: a bare Go finalizer can fire (and free the native key) while a C.dstu_* call using that same pointer is still in flight, since the last Go-side reference becomes the call argument itself, not the wrapper struct - SafeHandle avoids this because P/Invoke marshalling itself roots the handle for the call’s duration, which a plain finalizer does not replicate. See D-155 for the full mechanism and why it was invisible to every test in this binding’s own suite (each one holds its key reachable via defer across the whole test function). Every []byte-taking wrapper guards the empty-slice case (unsafe.Pointer(&b[0]) panics on len(b)==0, and the header documents zero-length input as legal throughout) via a shared cBytes() helper. CryptoError/ArgumentError/InternalError mirror .NET’s DstuException/ArgumentException split (cross-language style guide principle 4).
  • Step 3: Done. SecretStreamEncryptWriter/SecretStreamDecryptReader (io.Writer/io.Reader-shaped) for crypto_secretstream — the idiomatic fit here, same reasoning as C++’s istream/ostream. D-118’s shape: Close() never emits a Final chunk (Go’s defer has no exception-type parameter, same reasoning as .NET’s Dispose()/Complete() split); the reader bounds the untrusted chunk-length prefix against SecretstreamChunkBytes and rejects trailing bytes after Final.
  • Step 4: Done, local/repo-relative only. No true prebuilt-artifact/registry story exists for this binding yet — unlike every other binding, dstu/dstu.go’s own #cgo LDFLAGS uses ${SRCDIR}-relative paths into target/release, so bindings/go only builds from inside a checkout of this repo with dstu-core-capi already built there, not as a standalone go get-able module. Flagged explicitly in the binding’s own README rather than silently glossed over.
  • Step 5: Done. cargo xtask go (build dstu-core-capi, gofmt -l via a dedicated output- capturing check since gofmt itself always exits 0, go vet, go test) + bindings-go.yml CI (own job, D-119 reasoning). The Windows CI leg forces the GNU-hosted Rust toolchain as the default (not just an additional cross target) and installs MinGW-w64 via choco, since cgo cannot link against dtolnay/rust-toolchain@stable’s default MSVC-hosted output on windows-latestunconfirmed on real CI as of this writing, flagged in the workflow’s own header comment (same “confirm on real CI, not just locally” posture as D-147/D-149).
  • Step 6: Done. Go’s own testing package, three categories (D-64/D-65) — official Kupyna-256 vector via the shared JSON, real byte-for-byte uacrypt interop for secretstream, tamper/wrong- key rejection across secretbox/auth/sign/secretstream, misuse (wrong-length keys/tags/context, truncated/oversized/trailing-data secretstream input, double-finalize, write-after-Complete).
  • Step 7: Done. examples/ (five runnable programs mirroring bindings/python/examples/ bindings/dotnet/examples file-for-file, each actually run against the real built library) + README.md with the provisional-status banner, including the step-4 repo-relative caveat.
  • Step 8/9: Done — this entry, plus docs/DECISIONS.md D-155, docs/TASKS.md, README.md, docs/dstu-crypto-project.md, docs/release-readiness.md.
  • Step 10: Done. Real aarch64 Linux (the Raspberry Pi rig) had no Go toolchain at all before this — installed the official linux-arm64 1.26.5 tarball (Debian’s own apt package is a stale 1.19). Found one real gap, not an ARM-portability bug: the cgo LDFLAGS written on the Windows dev machine (-lws2_32 -luserenv -lntdll) are Windows-only and failed to link at all on Linux — fixed with cgo’s own per-GOOS #cgo pragma syntax (#cgo windows LDFLAGS: .../#cgo linux LDFLAGS: .../#cgo darwin LDFLAGS: ...), each platform getting its own full flag set rather than a shared base plus a negated exclusion. All tests passed after the fix, including the real uacrypt interop test and all 5 examples (output byte-identical to the Windows run where comparable) — see D-155 for the full account.

Dart — raised in the same conversation, explicitly deferred (D-122), not scheduled. Same reasoning as Node’s own browser/WASM scoping (D-118): Dart’s primary audience (Flutter mobile/web) overlaps least with this project’s demonstrated PKI/enterprise/security-tooling demand. Revisit if real demand evidence appears, same as any other out-of-scope language would need.

T-181 — crypto_box across all eight bindings (added 2026-08-06)

Incremental, not a from-scratch binding phase — every one of the eight bindings below already exists (T-49 through T-163 above), each with its own scaffold, packaging, xtask/CI wiring, and doc-map entries already in place. This phase adds exactly one new module’s surface (dstu_core::crypto_boxSecretKey/PublicKey/seal/open, D-169) to each, so most of “The standard binding steps” above collapse: no new step 1 (scaffold), step 4 (packaging), or step 5 (xtask/CI wiring) per language — only steps 2 (wrap the surface), 6 (tests), 7 (examples/README), 8 (doc-map sweep), 9 (commit per step) apply, plus step 10 (Pi smoke check) once per language still worth running since it caught a real bug before (D-151). Step 3 (secretstream wrapping) does not apply — crypto_box::seal/open are one-shot, not a stream.

Prerequisite closed first, not trailing behind: advisor() flagged that three of the eight languages below (.NET, Go, C++ — the real C-ABI-consuming group, per each binding’s own Cargo.toml/ build config, not Fork 1’s original planning text above which incorrectly also names PHP) cannot wrap crypto_box at all until dstu-core-capi has it. T-178c (crates/dstu-core-capi/src/ crypto_box.rs, D-171) landed first this session specifically to unblock this phase, not as a trailing footnote the way T-178’s own original plan had it. PHP turned out not to need it at all - confirmed only once its own crypto_box.rs was actually being written (2026-08-06): its Cargo.toml depends on dstu-core directly, the same direct-ext-php-rs-binding shape as Python/ Node/Ruby, contradicting Fork 1’s original “PHP follows C++/.NET” text above (now corrected there too). Re-check a binding’s actual dependency before assuming Fork 1’s planning-time text still describes it — it was written before any binding existed.

Order (grouped by what a language actually links, confirmed per binding, not assumed from Fork 1’s original planning text):

  1. Python/Node.js/Ruby/PHP — direct FFI (PyO3/napi-rs/magnus/ext-php-rs), no C ABI involved. Done 2026-08-06 (all four, PHP included once its real dependency shape was confirmed). Python first as the template every other language’s crypto_box wrapper checked itself against, matching T-49’s own original role.
  2. .NET/Go/C++ — consume dstu-core-capi’s now-complete crypto_box C ABI (T-178c) directly: P/Invoke (.NET), cgo (Go), the generated header + link (C++). Done 2026-08-06.
  3. Java — last, per Fork 1’s own note that Java gets an explicit spike (jni crate direct vs. JNI-over-the-C-ABI) before committing to a shape; do that spike once, for crypto_box specifically if the original Fork 1 spike (recorded when it runs, docs/DECISIONS.md) didn’t already settle it for every future module this binding adds.

What “wrap the surface” means per language, concretely: a keypair type (generate + from/to bytes, mirroring crypto_sign’s own SigningKey/VerifyingKey idiom each binding already has), a seal(message, public_key) -> bytes and open(sealed, secret_key) -> bytes pair (or the language’s own idiomatic error-return shape — exception, Result, (value, err) — matching how that binding already surfaces crypto_secretbox’s open failure). No new streaming primitive — seal/open already documented as not memory-bounded at the Rust/C-ABI layer (D-169/D-171), the binding inherits that limitation, document it in the same place crypto_secretbox’s own binding wrapper already notes its own non-streaming nature.

Test-vector note: no DSTU vector oracle exists for this composite construction (D-169’s own “Provenance” section — same as crypto_secretstream, D-68) — every binding’s local test suite verifies round-trip/rejection/misuse only, not a shared fixed-vector JSON the way Kalyna/Kupyna/DSTU 4145 bindings’ tests do. A cross-language round-trip check (seal in one binding’s test process, open via the Rust core directly, or vice versa) is worth adding once at least two bindings exist, to catch a wire-format assumption divergence early — not required before the first binding lands.

After all eight land: T-180’s remaining gh-pages scope (mentioning DSTU 9041/crypto_box on the public site) happens here, not before — per the owner’s own 2026-08-06 instruction (“update gh-pages after all the tasks”). This mirrors T-162’s own precedent exactly (site refresh only after every binding it covers actually exists) — expect a smaller version of T-162’s own checklist above, not a full re-run, since the rest of the site’s binding-facing content doesn’t change.

T-204 — crypto_box512/crypto_sign257 across all eight bindings + capi (closed 2026-08-09/10)

Same incremental shape as T-181 — two new modules’ surfaces (dstu_core::crypto_box512, T-193/D-182; dstu_core::crypto_sign257, T-199/D-185/D-186), each a direct sibling of an existing wrapped module (crypto_box, crypto_sign) at a different curve size, added to every binding that already exists. No new step 1/4/5 per language, same as T-181’s own reasoning.

Order, three phases: (1) dstu-core-capi first (crates/dstu-core-capi/src/box512.rs, sign257.rs) — unblocks the C-ABI-consuming group, same dependency reason T-181 cites for crypto_box. (2) .NET/Go/C++ — consume the now-complete C ABI directly. (3) Python/Node.js/Ruby/ Java/PHP — direct-Rust wrappers, each against its own macro system (PyO3/napi-rs/magnus/jni/ ext-php-rs). All three phases done same session.

One binding-specific bug found: ext-php-rs’s #[php_function] macro’s default RenameRule::Snake splits a letter/digit boundary (box512box_512), silently mis-registering all 8 new PHP function names — fixed with an explicit #[php(name = "...")] override per function (memory: php_ext_php_rs_digit_rename_pitfall). No equivalent issue in any other binding’s own macro system.

No new curve-tag dispatch anywherecrypto_sign257/crypto_box512 are distinct function/ type names in every binding, same as the C ABI and core itself; tag-byte dispatch stays a uacrypt-CLI-only concern (D-118’s “don’t duplicate wire-format logic into every binding” lesson generalized).

Publishing (all registries) — separate, owner-gated, not scheduled

One explicit ask per registry (PyPI/npm/Maven Central/NuGet/RubyGems/Packagist), the same class of decision T-17 already applies to crates.io. Not started, not broken into steps above — tracked only once actually requested.

T-162 — GitHub-facing docs + gh-pages site refresh (last, after every binding lands)

Done in full 2026-08-03. Requested 2026-08-02: once all bindings above exist, the project’s public-facing surfaces — README.md, the doc set under docs/, and the separate gh-pages branch site (the landing page docs/PERFORMANCE.md/docs/TASKS.md already reference, e.g. its orientation table naming AES/Whirlpool/ChaCha20 as role-analogs) — need a pass to actually mention the bindings, not just the Rust crate/CLI. This is a documentation-only task, no primitive/binding code changes.

  1. Done. Re-read the gh-pages branch’s current content via the existing local worktree (git fetch origin gh-pages + diff against origin/gh-pages to confirm it was in sync) - not assumed from memory. Found the site is a single bilingual page (index.html/ uk/index.html, identical body content, differing only in <head> metadata and the language-switch link - confirmed by diffing the two files before editing) with zero mention of any language binding anywhere, Rust/CLI-only throughout.
  2. Done. README.md: new “Language bindings” section (table, all eight, approach + README link each, honest “not published to any registry yet” status, C ABI cross-reference) added right after “Using uacrypt”. The repo tree already listed all eight bindings (landed incidentally as part of T-53’s own step 8 doc-map sweep, before this task started).
  3. Done. docs/dstu-crypto-project.md’s “Second priority” section was already current (same T-53 step 8 sweep - “every planned binding is now built”). docs/release- readiness.md’s “Phase 3” line had one stale leftover phrase from Python/Node’s own landing day (“First two bindings done”) - fixed to the accurate all-nine count.
  4. Done. gh-pages updated - real new content existed (see step 1’s finding). Added a bilingual “Eight languages, one C ABI” section between the existing “Try it” and “Status” sections in both index.html and uk/index.html: a check-grid of eight cards (one per language, approach + a link to that binding’s own README.md on GitHub, reusing the site’s own existing CSS component rather than inventing a new one) plus a callout.neutral noting the C ABI itself is usable from any C-FFI-capable language, not just the three (.NET/Go/ C++) that consume it directly. Since browser automation wasn’t available this session, the edited file was sent directly to the owner for a real visual check before pushing (not assumed correct from reading the markup alone) - confirmed, pushed, commit 43e8022.
  5. Done. Doc-map sweep: docs/user-journey-gaps.md/docs/cross-language-style-guide.md checked, nothing stale found (same result every earlier binding’s own step 8 had). T-162 marked done in docs/TASKS.md.
  6. Done. Each step above landed as its own commit on master; the gh-pages change is its own commit on that separate branch (a different branch’s commit history, not master’s one-step-per-commit sequence, but the same discipline - one change, one commit, not a mixed drop).

Doc-map sweep discipline

Landing any phase above touches more than docs/TASKS.md — grep that phase’s task ID across README.md (repo tree), docs/dstu-crypto-project.md (“Second priority” line), docs/release-readiness.md (“Phase 3” line), docs/user-journey-gaps.md (new persona per binding), and docs/cross-language-style-guide.md’s “applies today to” line, before calling that phase done — CLAUDE.md’s own agent-discipline notes record this exact failure mode happening once already for crypto_secretstream (D-68) and warn against repeating it here at five-times the scale.