Skip to content

feat(rust): consolidate SDK semantics into a Rust core with five language surfaces - #319

Open
jacksmithinsulander wants to merge 33 commits into
devfrom
jack/rust-core-sdk-rewrite
Open

feat(rust): consolidate SDK semantics into a Rust core with five language surfaces#319
jacksmithinsulander wants to merge 33 commits into
devfrom
jack/rust-core-sdk-rewrite

Conversation

@jacksmithinsulander

@jacksmithinsulander jacksmithinsulander commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Description

TL;DR — All semantic logic in the SDK (validation, retry schedules, webhook verification, paywall/helper decisions, MCP builders, the error model, and all 36 client operations) now lives in a Rust core. The TypeScript packages became thin facades over that core via napi and WASM. Four new language surfaces (Python, Ruby, Go, and a C ABI scaffold) are generated from the same contract, so they cannot drift by construction. Correctness is argued from 535 shared golden fixtures and a set of drift gates rather than from re-reviewing 1,773 files. @solvapay/* package names and all React imports are unchanged by design — but @solvapay/server gains a hard dependency on @solvapay/server-wasm, which changes install shape.

This is large but mechanically constrained: ~90% of the diff is generated or fixture data, and every generated file is regeneration-gated in CI. See "How to review a 1,773-file diff" below for the reading order.

Why

The motivation is semantic consolidation, not performance (redesign v2 §1). Before this change, the same business rule — how a webhook signature is verified, when a retry is attempted, what a paywall gate decides — was implemented once per language surface and kept in sync by review discipline. That does not scale past one surface, and it had already produced divergence.

After this change there is exactly one implementation of each rule, and divergence becomes a build failure rather than a support ticket. Adding a language surface is now a codegen target, not a reimplementation.

Explicit non-goal: performance is not the rationale. The perf budgets below are regression guards, not a claimed win.

Architecture

graph TD
  manifest["contract/manifest/sdk-contract.yaml + OpenAPI snapshot"]
  dtogen["rust/tools/dto-gen (shared IR)"]
  core["solvapay-core (pure decisions, no tokio, wasm32-safe)"]
  dto["solvapay-dto (generated)"]
  transport["solvapay-transport (reqwest | Fetch)"]
  facade["solvapay crate facade"]
  napi["bindings/node (napi-rs)"]
  wasm["bindings/wasm (wasm-bindgen)"]
  other["bindings/python | ruby | go | c"]
  ts["@solvapay/server, @solvapay/core, @solvapay/mcp-core"]
  react["@solvapay/react (imports unchanged)"]
  manifest --> dtogen
  dtogen --> dto
  dtogen --> napi
  dtogen --> wasm
  dtogen --> other
  dto --> core
  core --> transport
  transport --> facade
  facade --> napi
  facade --> wasm
  facade --> other
  napi --> ts
  wasm --> ts
  ts --> react
Loading

Four crates under rust/ (toolchain pinned to 1.96.0; workspace lints deny unwrap_used, expect_used, panic, and missing_docs):

  • solvapay-core — pure semantics: validation, retry schedules (it computes delays, it never sleeps), webhook verification, paywall and helper decision cores, MCP builders, SdkError. It never reads env and never performs I/O. It is not dependency-free — it depends on solvapay-dto for the frozen error templates — and it is not no_std; browser size is controlled by the Cargo feature graph (server / browser / client-public) plus a WASM symbol audit.
  • solvapay-dto — fully generated (schemas.rs, routes.rs, overlays.rs, error_templates.rs).
  • solvapay-transport — the Transport trait, ClientShell (auth, base URL, idempotency, retry, HTTP→SdkError mapping), and the 36 typed SolvaPayClient methods in src/client.rs, landed in three groups (A: 10, B: 5, C: 21). Native uses reqwest + rustls; wasm32-unknown-unknown uses FetchTransport; the Go wasm32-wasip1 guest uses neither and receives HTTP through wazero host imports. Note ClientShell defaults to max_retries: 0 for TS parity — withRetry deliberately stays facade-side.
  • solvapay — the public crates.io facade. No new logic, plus optional blocking sync twins.

Codegen is dual-input. The OpenAPI snapshot owns wire shapes; contract/manifest/sdk-contract.yaml owns public names across five languages, overlays, idempotency, frozen error messages, docs, the sync matrix, and the bindings: descriptors. Both lower into one IR in rust/tools/dto-gen/src/ir.rs, which emits the Rust DTOs, the TypeScript .d.ts plus native.ts / wasm.ts, every binding shim, and the Python/Ruby/Go/Rust facades.

Every binding shares one ABI: a JSON envelope, {"ok":true,"value":…} or {"ok":false,"error":…}, identical across napi, wasm-bindgen, PyO3, Magnus, and wazero. 102 binding symbols are snapshotted in contract/manifest/binding-symbols.snapshot.json — more than the 36 operations, because bindings also cover the sync decision cores and payload builders.

Binding surfaces: node (napi-rs 3 → @solvapay/server-native plus 8 per-target packages and a wasm32-wasi fallback behind a tri-state NAPI_RS_FORCE_WASI loader), wasm (wasm-bindgen 0.2.126 → @solvapay/server-wasm, with mutually exclusive edge and browser profiles), python (PyO3/maturin), ruby (Magnus/rb-sys, no Windows prebuilds), go (wazero + embedded WASM, released via subtree split to github.com/solvapay/solvapay-go), and c (cbindgen — scaffold only, hand-maintained dispatch allowlist, no published artifact).

The thin-facade rule, and what enforces it

The load-bearing invariant of the whole design: facades do type conversion, env/config resolution, and host concerns (timers, caches, event loops) only. No decision logic. If that invariant erodes, the consolidation is undone silently. Eight gates hold it:

Gate Enforces A failure means
pnpm gen:check Regenerate, then diff 41 generated path groups against HEAD A generated file was hand-edited, or the emitter changed without regeneration
pnpm manifest:check Manifest schema + semantic rules + cross-check against the OpenAPI snapshot The contract is internally inconsistent or has drifted from the wire spec
pnpm snapshot:openapi:check Snapshot is byte-identical and the derive is idempotent The upstream spec moved, or the derive is non-deterministic
pnpm parity:check Manifest catalog vs. the real TS surface, read through the TypeScript Compiler API A portable export exists that the contract does not know about
pnpm delegation:check Every value export carries a delegation marker or an entry in contract/delegation-allowlist.json Logic was reintroduced into a facade instead of the core
pnpm server-superseded-ts:check No verifyWebhookTs, tsFallback, SOLVAPAY_IMPL, or fetch( left in client.ts A deleted TS implementation path is creeping back
@generated header gate Generated files declare themselves An emitted file lost its provenance marker
No-unwrap gate (two layers) clippy deny plus rust/scripts/check-no-unwrap.sh A panic path entered the core

delegation:check and server-superseded-ts:check are the two that specifically prevent the architecture from rotting back into per-language logic. Treat changes to contract/delegation-allowlist.json as architecture changes.

What this means for TypeScript consumers

Package names and React imports are unchanged. The install shape is not, and the fallback behaviour is deliberately asymmetric:

  • @solvapay/server: @solvapay/server-wasm is a hard dependency (edge and WASM paths); @solvapay/server-native is optional (napi, with a WASI fallback for exotic platforms). The per-target @solvapay/server-native-* packages arrive transitively — integrators add nothing to their manifests.
  • Missing bindings throw. There is no silent TS fallback. @solvapay/server and @solvapay/core are Rust-only after steps 52/53. The errors are 'core sync API not installed', 'server sync API not installed', or a SolvaPayError naming @solvapay/server-native.
  • @solvapay/mcp-core is the deliberate exception — it still keeps a TS fallback when the ambient sync API is not installed. Stated explicitly so nobody "fixes" it for consistency.
  • Browser: sync core helpers need import '@solvapay/core/browser-wasm' (which @solvapay/react does for you) or an explicit warmBrowserCoreWasm().
  • Next.js: use withSolvaPayNextConfig from examples/typescript/shared/solvapay-next-config.mjs — it sets serverExternalPackages and aliases @solvapay/server-native: false on the client. Prefer --webpack: Turbopack rebundles workspace packages and breaks napi resolution.
  • No env-flag rollback exists. SOLVAPAY_IMPL was introduced mid-migration and removed again before merge. Rollback is a package downgrade, not a flag flip.
  • Python, Ruby, and Go are built and tested in CI but are not GA. Their publish workflows default to dry-run / TestPyPI. Please don't market them yet. The C ABI is a scaffold with no published artifact.

How to work in this repo now

Day-to-day loop:

pnpm gen                 # regenerate; gen:check in CI
pnpm manifest:check
pnpm snapshot:openapi:check
pnpm test:contract
pnpm shadow:selftest
pnpm parity:check
pnpm delegation:check
pnpm test:clean-install

cargo test --workspace
cargo run -p fixture-runner -- ../contract/fixtures

Adding an operation — the most common future task — is pnpm gen:scaffoldpnpm gen:bindingspnpm gen. Full runbook in docs/contributing/sdk-codegen.md.

Hooks are split deliberately (see scripts/setup-pre-commit-hook.sh): pre-commit re-runs pnpm gen only when the manifest or OpenAPI snapshot is staged; pre-push runs gen:check, manifest:check, and parity:check; everything else is CI-only. That keeps the common commit fast without letting drift reach a shared branch.

How to review a 1,773-file diff

Reading order:

  1. docs/contributing/rust-core-sdk-redesign-v2.md — spec and rationale (the "why", 55 steps).
  2. docs/contributing/architecture.md — as-built reference.
  3. contract/manifest/sdk-contract.yaml — the contract everything is generated from. This is the highest-leverage file in the PR.
  4. rust/crates/solvapay-core — the semantics themselves. Second-highest leverage.
  5. rust/crates/solvapay-transport/src/shell.rs and packages/server/src/client.ts — the seam between core and facade.
  6. One binding end-to-end (rust/bindings/node is the most load-bearing).
  7. The TS delegation shims, then the CI gates in .github/workflows/ci.yml.

Skim rather than read: anything marked @generated (drift-gated — reviewing it reviews the emitter twice), contract/fixtures/ (535 JSON goldens), and the examples/*examples/typescript/* relocation, which is a move with no logic change.

Base is dev. Note that a squash merge collapses 31 commits whose messages carry the step numbering used throughout the design docs — consider a merge commit if that history is worth keeping.

Verification

  • Fixtures: 535 files. fixture-runner reports parsed=535 executed=431 passed=431 skipped-unbound=104. The 104 skipped are all client/* — they have no Rust HTTP binding in the runner registry and are exercised through the TS/shadow path instead. (executed=367 appears in older commit messages; that is a historical step-31 milestone, not the current number.)
  • Shadow mode: 38 scenarios covering all 36 operations, comparing the napi/WASM-backed TS facade against shadow-invoker. Both sides are Rust after step 53, so this is now a marshalling and normalization check, not a TS-vs-Rust equivalence check. Worth knowing before you read it as stronger evidence than it is.
  • CI: all checks green at time of writing (62 — Rust, the node/python/ruby/go/c/wasm binding matrices, 27 clean-install jobs, the Deno edge smoke, docs validation).
  • Perf budgets (rust/bindings/wasm/budgets.json), as regression guards only: browser 63,460 B gzip / 23.34 ms p20 cold start; edge 298,593 B / 28.02 ms. Tolerances are +10% bytes and +50% cold start.

Known gaps and risks

Read this section before approving.

  1. Binding-package publish ordering is the sharpest release risk. @solvapay/server-native and @solvapay/server-wasm sit at 0.1.0 under rust/bindings/* and are not in the Changesets ignore list. They must publish at or before @solvapay/server@2.0.1, or every consumer install fails to resolve @solvapay/server-wasm. This needs a deliberate release order, not luck.
  2. The changeset under-describes the change. pnpm changeset status --verbose resolves patch-only with no majors (@solvapay/core 1.2.1, @solvapay/server 2.0.1, @solvapay/react 1.6.1, @solvapay/next 1.3.1, @solvapay/mcp-core 0.2.9), which is correct on the versioning rules. The problem is content: the only changeset (.changeset/core-eager-wasm-catch.md) describes an unhandled-rejection fix and says nothing about @solvapay/server becoming Rust-only with a new hard dependency. As written, the published CHANGELOG will not tell consumers why their install shape changed. An added changeset is needed before release.
  3. Step 55 of 55 is outstanding — "promote all compatibility gates" (docs/contributing/rust-migration-map.md).
  4. A workerd smoke fixture exists but is not wired into CI. rust/bindings/wasm/scripts/workerd-edge-smoke/ is unreferenced in ci.yml; only the Deno edge smoke runs. Cloudflare Workers is a first-class target, so this is a real coverage gap even though the artifact exists.
  5. The migration map is stale in two places, both benign: it lists step 39 as "In progress" although the 27 clean-install jobs are now green, and it records a "Known-not-green" parity:check with 24 uncatalogued core extras that 797051e0 / 00a28069 fixed — parity:check passes locally and runs inside the green lint-build-test job.

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Refactoring (no functional changes)
  • Documentation update
  • Build/CI changes
  • Test addition or update

Not marked as a breaking change: no public TS API was removed or renamed, and Changesets resolves no majors. The consumer-visible change is install shape (@solvapay/server-wasm becomes a hard dependency, and a missing binding throws instead of falling back to TS), which is called out above and needs to reach the CHANGELOG.

Related Issues

Related to the Rust core SDK redesign track (docs/contributing/rust-core-sdk-redesign-v2.md, steps 1–54 of 55).

Changes Made

  • Added four Rust crates (solvapay-core, solvapay-dto, solvapay-transport, solvapay) holding all SDK semantics, with unwrap/panic denied workspace-wide.
  • Added rust/tools/dto-gen: a dual-input (OpenAPI snapshot + sdk-contract.yaml) generator lowering to one IR and emitting Rust DTOs, TS declarations and delegation shims, every binding shim, and the Python/Ruby/Go/Rust facades across 41 generated path groups.
  • Converted @solvapay/server, @solvapay/core, and @solvapay/mcp-core into thin facades over napi (@solvapay/server-native) and WASM (@solvapay/server-wasm); deleted the superseded TS implementations and the interim SOLVAPAY_IMPL flag.
  • Added Python (PyO3), Ruby (Magnus), Go (wazero), and C (cbindgen scaffold) bindings over one shared JSON-envelope ABI, with 102 symbols snapshotted.
  • Added the verification system: 535 shared golden fixtures, a Rust fixture-runner, 38 shadow scenarios over all 36 operations, and eight drift/architecture gates in CI.
  • Added 4,243 lines of design documentation under docs/contributing/ and relocated examples/* to examples/typescript/*.

Changeset

  • I ran pnpm changeset and committed the generated file
  • My changeset selects the right bump level per package (patch / minor / major)

Caveat, repeated from "Known gaps" because it matters at release time: the existing changeset's bump levels are right but its body does not describe the install-shape change to @solvapay/server. An added changeset is required before publishing.

Testing

  • pnpm test — unit tests pass
  • pnpm build — full monorepo build passes
  • pnpm tsx scripts/validate-fetch-runtime.ts — Web-standards runtime gate passes
  • Manual testing completed
  • Tested in relevant environments (Node / Deno / Bun / Next edge / browser / Python / Ruby / Go)

Also green: cargo test --workspace, cargo run -p fixture-runner (431/431 executed), pnpm test:contract, pnpm shadow:selftest, pnpm gen:check, pnpm manifest:check, pnpm snapshot:openapi:check, pnpm parity:check, pnpm delegation:check, pnpm server-superseded-ts:check, pnpm test:clean-install (27 CI jobs). Not covered: the workerd edge smoke (gap 4 above).

Checklist

  • My code follows the project's style guidelines (pnpm lint / pnpm format)
  • I have performed a self-review of my code
  • I have commented my code in hard-to-understand areas (not narration comments)
  • I have updated the documentation (README.md, docs/, package READMEs) accordingly
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Additional Notes

Design documentation is checked in and is the intended source of truth — this description routes to it rather than restating it:

jacksmithinsulander and others added 30 commits July 16, 2026 11:09
Freeze the /v1/sdk/* OpenAPI surface as a checked-in source + canonical
snapshot, share the filter/prune/placeholder pipeline with generate-types,
and gate offline idempotence in CI for the Rust core migration Step 1.

Co-authored-by: Cursor <cursoragent@cursor.com>
Catalog the 36-client public surface with five-language names, error
templates, and OpenAPI cross-checks so Phase 0 parity work has a single
canonical API source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ures

Phase 0 Steps 3–4: Zod fixture schema + replay harness with dual node/edge
verifyWebhook bindings, and the full §6.1 webhook axis set for Rust migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pin Rust 1.96.0, add solvapay-core + empty-suite fixture-runner, and wire CI for fmt/clippy/no-unwrap/wasm32/no-tokio gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
…9–24)

Land Phase 1–3 progress: pure core logic, dto-gen/overlays, HTTP transport,
ClientShell, and all 36 typed SolvaPayClient methods with fixture parity on
reqwest and Fetch, plus OPERATION_NAMES coverage gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
…write

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	pnpm-lock.yaml
Port auth/customer-sync/activation, payment/checkout, balance-poll, and
purchase/renewal decision cores to solvapay-core with golden fixtures,
TS extracts, and shadow-mode identity checks. Phase 4 still ships dark.

Co-authored-by: Cursor <cursoragent@cursor.com>
Port usage/limits/plans and merchant/product/error decision cores to
solvapay-core with TS pure extracts, golden fixtures, and characterization
suites; close Phase 4 at executed=367.

Co-authored-by: Cursor <cursoragent@cursor.com>
Port paywall decision/payload cores and MCP envelope/descriptor metadata
into solvapay-core with golden fixtures; close Phase 5 ahead of napi cutover.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add solvapay-node with napiVersion/verifyWebhook smoke, per-target
optionalDependency layout + WASI fallback, hard artifact gate, and CI matrix.

Co-authored-by: Cursor <cursoragent@cursor.com>
…teps 37–39)

Wire SOLVAPAY_IMPL Node napi and edge wasm-bindgen verifyWebhook paths, then
prove publish-shaped npm installs across the §7.7 matrix plus WASI.

Co-authored-by: Cursor <cursoragent@cursor.com>
Delegate the @solvapay/server edge surface (client Groups A–C, sync
decisions/paywall/retry, core + mcp-core installs) to @solvapay/server-wasm
with SOLVAPAY_IMPL rollback, initSync sync path, browser public-safe opt-in,
and workerd/Deno smoke gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
Enrich the manifest bindings descriptor and dto-gen so the eight committed
37R/38R shim files regenerate byte-identically (plus @generated headers),
with CI regen-drift and header gates covering the retrofit.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve dist/native.js via a dedicated tsup entry and createRequire fallback
so webpack/Turbopack cannot rewrite the Node-only graph. Wire checkout-demo
rust smoke, boot assertions, and impl diag routes for SOLVAPAY_IMPL=rust.

Co-authored-by: Cursor <cursoragent@cursor.com>
Regenerate the Node/edge TS glue from Ir.binding_symbols with chrome
assets, prove header-only retrofit against the committed 37R/38R files,
and extend CI regen-drift + @generated gates to cover both paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
Ship Phase 7–8 bindings, shared IR doc-model + TSDoc (18T), and Python
strict .pyi/py.typed gates with mypy/pyright/ruff (42T).

Co-authored-by: Cursor <cursoragent@cursor.com>
Emit richer RBS/YARD from the contract, add Steep/RuboCop gates in CI, and regenerate the Ruby client so type-strictness and hover docs stay in sync with dto-gen.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add publish-graph checks, dry-run/shadow workflows, dto-gen Rust client/parity emitters, and live-contract tooling so the crate train can be verified before first crates.io upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
Make @solvapay/server Rust-only (Step 53): delete webhook/paywall/retry/client
TS bodies, inject optional napi webhook clock, drive fixtures/shadow via WASM
FetchTransport, and gate regressions with server-superseded-ts. Also include
the Step 52 core facade deletion, Step 54 C ABI scaffold, Go binding artifacts,
and examples/typescript relocation already staged on this branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the vestigial ts/rust selection flag after Steps 52/53 made core and
server Rust-only. Always dispatch via napi/WASM (throw when missing);
mcp-core keeps its TS path when the binding is uninstalled. Sweep CI,
smokes, shadow, and docs accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
Steps 52/53 re-exported decision helpers from the TS surface without
cataloguing them in the cross-language operations map. Add them to
TS_ONLY_ALLOWLIST so pre-push parity:check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Teach the extras loop to treat bindings catalog names.ts as catalogued so
cross-language decision helpers no longer need TS_ONLY_ALLOWLIST entries.

Co-authored-by: Cursor <cursoragent@cursor.com>
Skip file: optionalDeps in deps:check, harden no-unwrap brace tracking,
exclude solvapay-wasm from workspace Rust CI, build auth for server unit
jobs, clear manylinux RUSTC_WRAPPER, fix Windows pip self-upgrade, set
aarch64 ring CFLAGS, soften go wasm bit-identity, and fix Ruby crate
name collision with the solvapay facade.

Co-authored-by: Cursor <cursoragent@cursor.com>
Break the server↔mcp-core turbo build cycle, export Init_solvapay for the
Ruby gem, normalize Windows fixture path separators, retake wasm cold-start
baselines from the CI host, and disable oxidize-rb cargo-cache on
aarch64-linux where cargo-binstall has no musl binary.

Co-authored-by: Cursor <cursoragent@cursor.com>
Swallow eager browser-WASM warm rejection for Node/SSR, fix win32 npm spawn
EINVAL, reclaim root-owned rust/target after manylinux, replace retired
macos-13, drop broken x64-mingw-ucrt Ruby, and harden WASM cold-start as p20
with a wider wall-clock tolerance.

Co-authored-by: Cursor <cursoragent@cursor.com>
Swallowing eager browser-WASM rejection unblocked Lint past package tests;
checkout-demo must install napi (and stub browser-wasm) like @solvapay/react.
wasm-binding now builds server-native so verify-webhook node cases can run
after the cold-start budget gate passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the static node:module import from register-virtual-tools-mcp so the
edge bundle no longer emits bare `import from "module"` (Deno rejects it).
Run solvapay-c tests single-threaded — the handle registry is process-global.

Co-authored-by: Cursor <cursoragent@cursor.com>
wasm-binding now reaches validate:fetch-runtime after earlier gates pass;
that check needs packages/mcp/dist/fetch built.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jacksmithinsulander jacksmithinsulander changed the title Jack/rust core sdk rewrite feat(rust): consolidate SDK semantics into a Rust core with five language surfaces Jul 26, 2026
jacksmithinsulander and others added 2 commits July 26, 2026 17:47
Wire all Next examples through a shared serverExternalPackages + webpack
config so @solvapay/server-native builds reliably, update example test
mocks for current Balance/Purchase types, expand CI example coverage,
and restore pnpm dev to the checkout-jack-local ngrok tunnel after the
examples/typescript relocation broke the ngrok.yml path.

Co-authored-by: Cursor <cursoragent@cursor.com>
createClient throws when Supabase URL/key are empty, which broke
/_not-found prerender in CI. Mirror hosted-checkout-demo by copying
env.example before build and guard client creation without credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant