Skip to content

feat(svalinn): ReScript→AffineScript/typed-wasm migration (phase 1)#46

Merged
hyperpolymath merged 11 commits into
mainfrom
claude/stapeln-maintenance-followup-iEUKy
May 15, 2026
Merged

feat(svalinn): ReScript→AffineScript/typed-wasm migration (phase 1)#46
hyperpolymath merged 11 commits into
mainfrom
claude/stapeln-maintenance-followup-iEUKy

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

What & why

Migrate svalinn off ReScript→JS onto AffineScript→typed-WasmGC
(hyperpolymath/affinescript compiler; ABI/conventions from
hyperpolymath/typed-wasm), per the decision to remove ReScript entirely
from svalinn (no "adaptors" carve-out).

This is a phased migration. This PR is phase 1: the full build
pipeline + host bridge + the first faithful module ports. The remaining
~27 modules are tracked below and land in follow-up commits to this branch.

Toolchain (confirmed)

  • hyperpolymath/affinescript — OCaml/dune compiler, source ext .affine,
    CLI affinescript compile <f> -o <f.wasm>. Pinned d2875a5.
  • hyperpolymath/typed-wasm — Rust+Idris2, the verified cross-language
    WasmGC ABI the emitted modules conform to. Pinned e90e2d1.
  • Deno hosts the WASM via the @hyperpolymath/affine-js bridge (same
    mechanism as affinescript-deno-test).

Architecture

AffineScript→WasmGC has no JS interop, no async, no in-language JSON, and
cannot own sockets. So svalinn becomes: pure logic/types in .affine

  • a Deno host (src/host/affine_host.js, plain JS — svalinn policy
    bans new TS) that owns the HTTP listener and supplies every extern
    (JSON arena, file/env, WASI fd_write stub). This is a re-architecture,
    not a transpile.

Landed in phase 1

Area File Status
Build Containerfile (4-stage: OCaml → Rust/Idris2 → wasm → Deno)
Build deno.json, scripts/affine-build.sh
Host src/host/affine_host.js (listener + externs + affine-js loader)
JSON protocol src/host/Json.affine
Types src/gateway/GatewayTypes.affine
Policy src/policy/PolicyEngine.affine (full pure core)
Entry src/Main.affine (serve, handle_evaluate)

Remaining modules (follow-up phases on this branch)

auth/{AuthTypes,JWT,Middleware,OAuth2}, gateway/{Gateway,Metrics,RateLimiter,SecurityHeaders},
mcp/{McpClient,McpTypes,Server,Tools}, vordr/{Client,VordrTypes},
validation/Validation (+ Ajv host extern), bridge/SelurBridge,
bindings/* (collapse into host), ui/* (browser WASM), tests/*.
Their routes currently return 501 in the host. .res files are kept
per-module until each port lands so the tree never enters a broken
half-state.

Honest caveats (please read)

  • The affinescript compiler is alpha (0.1.0-alpha.1, "Game
    Developer's Edition")
    with codegen issues logged upstream; stapeln's
    own ARCHITECTURE.md flags it as possibly not production-ready.
  • This environment has no OCaml/opam/dune, and the opam repo + wolfi
    base are off the network allowlist, so the WASM build could not be
    locally compiled/validated here
    . It is only exercised by the
    container build / CI (which can reach cgr.dev + GitHub).
  • The .affine ports are written against the studied upstream syntax &
    stdlib; expect compiler-shakeout fixes once CI runs the real build.
  • A full networked-server-in-WASM path is not upstream-proven; the host
    bridge is the pragmatic boundary that makes it tractable.

Draft until the build is green and more modules are ported.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv


Generated by Claude Code

claude added 11 commits May 15, 2026 20:11
Per the decision to move svalinn off ReScript onto AffineScript
(compiles to typed WasmGC via hyperpolymath/affinescript, ABI from
hyperpolymath/typed-wasm), this lands the build pipeline + host bridge
and the first faithful module ports.

Pipeline:
- Containerfile: 4-stage build — (A) build the affinescript OCaml
  compiler, (B) build typed-wasm (Rust/Idris2 ABI), (C) compile every
  src/*.affine → dist/wasm, (D) minimal Deno runtime hosting the WASM.
  Upstream tool repos pinned by commit.
- deno.json: rescript tasks/imports replaced with the affine build
  tasks and the @hyperpolymath/affine-js bridge.
- scripts/affine-build.sh: standalone .affine→.wasm compile driver.
- src/host/affine_host.js: Deno host that owns the HTTP listener and
  supplies every `extern` (JSON arena, file/env, WASI fd_write stub),
  loading WASM via the affine-js bridge. Plain JS to honour svalinn's
  language policy (TS banned; JS allowed for Deno-API glue).

Ported modules (faithful, idiomatic AffineScript):
- src/host/Json.affine        host-boundary JSON value protocol
- src/gateway/GatewayTypes.affine   data types (variant tags → enum + conv)
- src/policy/PolicyEngine.affine    full pure evaluation core + JSON
- src/Main.affine             WASM entrypoint + handle_evaluate handler

Remaining ~27 modules (auth/JWT/OAuth2, gateway/router/rate-limiter/
metrics, mcp, vordr, validation/Ajv, bridge, ui/*) are explicit
follow-up phases tracked in the PR; their routes return 501 until
ported. .res files are kept until each module's .affine port lands so
the tree never enters a broken half-state.

Caveat: the affinescript compiler is alpha (0.1.0-alpha.1) and the
OCaml/opam toolchain is unavailable in this environment, so the WASM
build was not locally validated; it is exercised by the container
build / CI. Documented in the PR.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
Two issues in one:

1. .gitignore `/src/**/*.js` (there to ignore ReScript-generated
   .res.js) silently excluded src/host/affine_host.js from the phase-1
   commit, so the Containerfile entrypoint pointed at an untracked
   file. Add a negation for src/host/ — that JS is hand-written
   Deno-API glue, not compiler output.

2. affine_host.js: expand the `put` arrow body's comma operator into a
   plain function (SonarQube new-issue flag); behaviour unchanged.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
…etrics)

Ports three gateway support modules to AffineScript, keeping the
established WASM/host boundary (pure logic in .affine; state, clock and
I/O in the Deno host):

- gateway/SecurityHeaders.affine — OWASP/CORS/rate-limit/error header
  sets as pure data; host applies them to every Response.
- gateway/RateLimiter.affine — pure sliding-window decision; host owns
  the per-IP map and supplies the wall clock (new now_ms extern).
- gateway/Metrics.affine — pure Prometheus text exposition; host owns
  the running counters/gauge/histogram totals.

Host bridge now: rate-limits every request, applies security headers to
all responses, and serves /metrics. Vordr container-count refresh
(I/O) remains a follow-up with the vordr port.

Remaining: auth/*, gateway/Gateway router, mcp/*, vordr/*,
validation/Validation, bridge/SelurBridge, ui/*. .res kept until each
port lands. Alpha-compiler / no-local-build caveat unchanged.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
Ports the pure, verifiable parts of the auth subsystem:

- auth/AuthTypes.affine — full faithful port: AuthMethod /
  PermissionAction enums + string conversions, Role/Permission/
  ApiKeyInfo/TokenPayload/AuthResult/UserContext structs, default RBAC
  roles and excluded paths. (ReScript `None` auth method → `NoAuth` to
  avoid the Option::None collision in AffineScript's prelude.)
- auth/Authz.affine — the security decision logic in one place:
  JWT standard-claim validation (exp/iat/iss/aud), scope check with
  the svalinn:admin super-scope, group membership, OAuth scope split,
  API-key prefix strip + expiry, mTLS CN extraction, and the alg→
  WebCrypto mapping the host uses for importKey/verify.

Inherently non-WASM pieces (JWT signature verification + JWKS fetch,
OAuth2 token/refresh/introspect/revoke HTTP, secure random, base64url)
remain host responsibilities and are a tracked follow-up to wire into
affine_host.js. .res kept until the auth route is fully cut over.
Alpha-compiler / no-local-build caveat unchanged.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
SonarCloud is unreachable from this environment (host not in the
network allowlist), so the issue was pinpointed by static self-audit.
Highest-confidence finding: javascript:S3353 — `let containersActive`
is never reassigned (the Vordr container-count refresh that mutates it
is a deferred follow-up). Fold it into a mutable `gauges` object so the
binding is `const` and the gauge is still updatable in place. Also make
the json_parse catch binding explicit (`catch (_e)`) to avoid the
bare-catch smell. Behaviour unchanged.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
The previous `catch (_e)` introduced an unused-binding smell (SonarCloud
new-issue count went 1→2). The optional bare `catch {}` is valid and
Sonar-clean. Keep the gauges refactor from the prior commit; only the
catch change is reverted.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
The actual SonarCloud new issue was not in affine_host.js but in
scripts/affine-build.sh:14 — "Use '[[' instead of '[' for conditional
tests" (Code Smell, Major, bash best-practices). The script is
#!/usr/bin/env bash with set -euo pipefail, so the bash [[ ]] keyword
is valid; swap the single POSIX-test usage.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
- vordr/VordrTypes.affine — faithful port: MCP request/error/response
  structs, Vörðr param structs, container/image types, and the tool-name
  constants (must match the Vörðr MCP adapter). Js.Json.t payloads →
  host Json arena handles.
- vordr/Client.affine — the pure JSON-RPC shaping: tools/call envelope,
  MCP response parse + error/result unwrap, monotonic id, and every
  per-tool argument builder. The transport (Fetch POST, /health ping)
  is inherently host I/O and stays in affine_host.js as tracked wiring.

Remaining: gateway/Gateway router (the orchestrator, 1219 LOC), mcp/*,
validation/Validation, bridge/SelurBridge, bindings/*, ui/*, plus host
wiring for auth crypto + vordr transport. .res kept until cutover.
Alpha-compiler / no-local-build caveat unchanged.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
"Verification is required" but it is impossible in the Claude sandbox
(no OCaml/opam, opam repo + wolfi base off the network allowlist).
This workflow is the verification path: in CI (which has network and
can run the OCaml toolchain) it builds the pinned upstream affinescript
compiler and compiles every svalinn src/**/*.affine to WebAssembly,
failing the check on any compile error.

Deliberately blocking (no continue-on-error) — unlike the advisory
smoke build, this exists to actually verify the ports. Pinned to the
same affinescript commit as the svalinn Containerfile.

No .res files were deleted: a cutover is only justified once this gate
is green, which cannot be asserted from this environment.

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
Packages the full remaining ReScript→AffineScript migration as a
runnable task for a local Claude Code CLI (where the OCaml/opam/deno/
docker toolchain exists and the work can actually be verified — the
cloud sandbox cannot). Captures: toolchain bring-up with the pinned
SHAs, the established architecture/conventions, the 11 done modules,
the remaining 20 + host wiring, the cutover, and 6 concrete
verification gates that constitute "verified".

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
The ocaml/opam image ships a stale opam index; opam install
--deps-only can fail to resolve the affinescript deps without a fresh
index. Add `opam update -y`. This is a near-certain CI infra fix; it
does not address potential .affine codegen failures, which require the
local toolchain to iterate (see AFFINE-MIGRATION-TASK.md gate 1).

https://claude.ai/code/session_01VPKWisqJq8wXSjq3mhPATv
@sonarqubecloud
Copy link
Copy Markdown

@hyperpolymath hyperpolymath marked this pull request as ready for review May 15, 2026 21:22
@hyperpolymath hyperpolymath merged commit f9ac322 into main May 15, 2026
35 of 36 checks passed
@hyperpolymath hyperpolymath deleted the claude/stapeln-maintenance-followup-iEUKy branch May 15, 2026 21:22
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.

2 participants