Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .github/workflows/svalinn-affine-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: PMPL-1.0-or-later
# svalinn AffineScript build/verify gate.
#
# The ReScript→AffineScript migration (PR #46) cannot be validated in the
# Claude sandbox (no OCaml/opam toolchain, opam repo off the network
# allowlist). This workflow IS the verification path: it builds the
# upstream affinescript compiler and compiles every svalinn .affine
# module to WebAssembly. A failure here is a real defect in the ports or
# the compiler pin — this is the gate that makes "verified" meaningful.
#
# This is intentionally a blocking check (no continue-on-error): the
# point is to verify, not to advise.
name: svalinn AffineScript build

on:
push:
branches: [main, master]
paths:
- 'container-stack/svalinn/src/**/*.affine'
- '.github/workflows/svalinn-affine-build.yml'
pull_request:
paths:
- 'container-stack/svalinn/src/**/*.affine'
- '.github/workflows/svalinn-affine-build.yml'

permissions:
contents: read

env:
# Pinned to the same commit the svalinn Containerfile uses.
AFFINESCRIPT_REF: d2875a552f1d389b4a60c4adfdc02ae53e36aca3

jobs:
affine-build:
name: compile svalinn .affine -> wasm
runs-on: ubuntu-latest
container:
image: ocaml/opam:debian-12-ocaml-5.1

steps:
- name: Install git/m4
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends git m4

- name: Checkout stapeln
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Build the affinescript compiler
run: |
set -euxo pipefail
git clone https://github.com/hyperpolymath/affinescript.git /tmp/affinescript
cd /tmp/affinescript
git checkout "${AFFINESCRIPT_REF}"
opam update -y
opam install --deps-only -y .
eval "$(opam env)"
dune build --release
cp _build/install/default/bin/affinescript /tmp/affinescript-bin
/tmp/affinescript-bin --version || true

- name: Compile every svalinn .affine module
run: |
set -euxo pipefail
cd "${GITHUB_WORKSPACE}/container-stack/svalinn"
mkdir -p dist/wasm
fail=0
while IFS= read -r -d '' f; do
base="$(basename "$f" .affine)"
echo "::group::compile $f"
if /tmp/affinescript-bin compile "$f" -o "dist/wasm/${base}.wasm"; then
echo "ok: $f"
else
echo "::error file=$f::affinescript compile failed"
fail=1
fi
echo "::endgroup::"
done < <(find src -name '*.affine' -print0 | sort -z)
if [ "$fail" -ne 0 ]; then
echo "::error::one or more svalinn .affine modules failed to compile"
exit 1
fi
ls -l dist/wasm

- name: Upload compiled wasm
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: svalinn-wasm
path: container-stack/svalinn/dist/wasm/
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions container-stack/svalinn/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ htmlcov/
/src/dist/
/src/**/*.js
/src/**/*.js.map
# ...but the AffineScript/typed-wasm migration's host bridge is
# hand-written JS (Deno-API glue), not compiler output — keep it tracked.
!/src/host/
!/src/host/*.js

# --- PhD Research ---
/data/
Expand Down
145 changes: 145 additions & 0 deletions container-stack/svalinn/AFFINE-MIGRATION-TASK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
# Task: complete the svalinn ReScript → AffineScript/typed-wasm migration (verified)

> **Run this with Claude Code in a local CLI that has the toolchain installed.**
> The cloud sandbox cannot build/run AffineScript (no OCaml/opam, opam repo
> + wolfi base off its network allowlist), so the cutover was deliberately
> NOT done there. Locally you can actually verify — that is the whole point.

## Objective

Finish migrating `container-stack/svalinn` off ReScript onto AffineScript
(compiles to typed WasmGC via `hyperpolymath/affinescript`; ABI from
`hyperpolymath/typed-wasm`), hosted by Deno. **Nothing stays in ReScript.**
Do not claim done until every verification gate below passes locally.

Work on branch `claude/stapeln-maintenance-followup-iEUKy` (PR #46, draft).
Commit per logical module; push; keep the PR draft until all gates pass.

## Prerequisites (must exist locally)

- `opam` + OCaml ≥ 5.1, `dune` ≥ 3.14, `m4`, `git`
- `cargo` (Rust) — for `typed-wasm`
- `deno`
- `docker` (or `podman`) — for the container build gate
- Network access to `github.com` (clones affinescript + typed-wasm)

## Toolchain bring-up (do once)

```bash
# affinescript compiler — PIN must match Containerfile + svalinn-affine-build.yml
git clone https://github.com/hyperpolymath/affinescript.git /tmp/affinescript
cd /tmp/affinescript && git checkout d2875a552f1d389b4a60c4adfdc02ae53e36aca3
opam install --deps-only -y . && eval "$(opam env)" && dune build --release
export AFFINESCRIPT_BIN=/tmp/affinescript/_build/install/default/bin/affinescript

# typed-wasm (ABI/conventions) — pin matches Containerfile
git clone https://github.com/hyperpolymath/typed-wasm.git /tmp/typed-wasm
cd /tmp/typed-wasm && git checkout e90e2d1a307c33d594d54065c902500da327977c
cargo build --release --locked
```

Read these upstream files before porting (they define syntax/stdlib/limits):
`/tmp/affinescript/examples/*.affine`, `stdlib/{prelude,string,io,result,Network,Crypto}.affine`,
`COMPILER-CAPABILITIES.md`, `KNOWN-ISSUES.md`, `affinescript-deno-test/`
(the `@hyperpolymath/affine-js` Deno bridge contract).

## Architecture & conventions (already established — keep consistent)

- **Boundary:** pure logic/types live in `.affine`; all I/O (sockets,
fetch, env, fs, crypto, JSON value type) is host-side in
`src/host/affine_host.js` (plain JS — svalinn policy **bans TypeScript**;
JS allowed for Deno glue).
- **JSON:** `.affine` has no JSON type. `src/host/Json.affine` declares
`extern` accessors; the host owns a handle arena (`0` = null/absent).
Re-use this protocol for all JSON.
- **AffineScript notes:** Rust-like (`struct`/`enum`/`fn`/`pub fn`/`match`/
`if`/`while`/`let`, generics `<T>`, `[T]`, `Option`/`Result` in prelude,
`len`, `string_sub`, `string_find`, `int_to_string`, `float_to_string`).
No async, no JS interop. `module Name;` header; `use Other;` imports;
`pub extern fn` = host import. **Pitfall:** prelude defines
`Option::None`, so don't name an enum variant `None` (we used `NoAuth`).
- Every file starts with `// SPDX-License-Identifier: PMPL-1.0-or-later`.
- One WASM module per top-level `.affine`; host loads by basename.

## Already done (11/31 — do NOT redo, mirror their style)

`src/host/Json.affine`, `src/Main.affine`, `src/gateway/GatewayTypes.affine`,
`src/policy/PolicyEngine.affine`, `src/gateway/SecurityHeaders.affine`,
`src/gateway/RateLimiter.affine`, `src/gateway/Metrics.affine`,
`src/auth/AuthTypes.affine`, `src/auth/Authz.affine`,
`src/vordr/VordrTypes.affine`, `src/vordr/Client.affine`.
Build pipeline (`Containerfile` 4-stage, `deno.json`, `scripts/affine-build.sh`)
and host bridge are in place. CI gate: `.github/workflows/svalinn-affine-build.yml`.

## Remaining work

Port each, applying the boundary rule (pure → `.affine`; I/O → host extern):

1. `src/gateway/Gateway.res` (≈1219 LOC, the router/orchestrator) →
`src/gateway/Gateway.affine` + host route wiring. Pure: routing
table, request/response shaping, error envelopes. Host: actual
`Deno.serve` dispatch (already host-owned) — expose `pub fn`
handlers per route and call them from `affine_host.js`.
2. `src/mcp/McpTypes.res` → `McpTypes.affine` (pure types).
3. `src/mcp/McpClient.res`, `src/mcp/Server.res`, `src/mcp/Tools.res` →
`.affine` pure protocol shaping; transport in host.
4. `src/validation/Validation.res` → `Validation.affine` pure field
accessors/policy logic; **Ajv schema validation is host-side**
(add `extern fn ajv_validate(schema_id, json_handle) -> ...`).
5. `src/bridge/SelurBridge.res` → `SelurBridge.affine` (+ host transport).
6. `src/bindings/{Deno,Fetch,Hono}.res` → delete; their role is
subsumed by `affine_host.js`. Remove all `.res` imports.
7. `src/vordr/Client.res` host wiring: implement the Fetch POST +
`/health` ping in `affine_host.js` calling the existing
`Client.affine` envelope/parse functions.
8. `src/auth/*` host wiring: implement JWT signature verify (WebCrypto
`crypto.subtle.importKey/verify`), JWKS fetch+cache, OAuth2 token/
refresh/introspect/revoke, secure random, base64url in
`affine_host.js`, calling `Authz.affine` for every decision.
9. `ui/src/*.res` (browser ReScript) → `.affine` compiled to WASM for
the browser (see upstream `affinescript-dom`/`affinescript-vite`),
or, if that path is not viable, raise it explicitly — do not silently
leave ReScript.
10. `tests/integration_test.res` → AffineScript tests via
`affinescript-deno-test` (`*_test.affine`, `pub fn test_* -> Bool`).

## Cutover (ONLY after every gate below is green)

- Delete every remaining `.res` under `container-stack/svalinn`.
- Confirm `deno.json` has no rescript tasks/imports (already done).
- Confirm `Containerfile` ENTRYPOINT is `src/host/affine_host.js`
(already done). Update `.gitignore` if any new dirs need tracking.

## Verification gates (ALL must pass locally — this is "verified")

1. `find container-stack/svalinn/src -name '*.affine' -print0 | \
xargs -0 -n1 -I{} "$AFFINESCRIPT_BIN" compile {} -o /tmp/x.wasm`
→ every module compiles, exit 0. Fix codegen issues; consult
`KNOWN-ISSUES.md` for compiler-side bugs/workarounds.
2. `cd container-stack/svalinn && deno check src/host/affine_host.js`
→ no errors.
3. `cd container-stack/svalinn && docker build -f Containerfile -t svalinn:affine .`
→ image builds (exercises all 4 stages incl. typed-wasm).
4. Run it and smoke every implemented route:
```bash
docker run -d -p 8000:8000 --name svalinn-aff svalinn:affine
curl -fsS localhost:8000/healthz
curl -fsS localhost:8000/metrics | grep svalinn_requests_total
curl -fsS -XPOST localhost:8000/v1/policy/evaluate \
-d '{"policy":{"version":1,"requiredPredicates":[],"allowedSigners":[],"logQuorum":0,"mode":"permissive"},"attestations":[]}'
# plus the gateway/auth/vordr/mcp routes once ported
```
All return expected status/body; no 501 for ported routes.
5. Push; the `svalinn AffineScript build` CI check on PR #46 is green
(it is intentionally blocking).
6. No `.res` remain under `container-stack/svalinn`; SonarCloud 0 new
issues; PR description module table updated to 31/31.

## Definition of done

All 6 gates green, PR #46 marked ready (not draft), zero `.res` in
svalinn, and a short note in the PR stating which gates were run and
their results. If the alpha compiler cannot compile a construct, record
the blocker explicitly in the PR — do not fake completion or silently
keep ReScript.
103 changes: 63 additions & 40 deletions container-stack/svalinn/Containerfile
Original file line number Diff line number Diff line change
@@ -1,67 +1,90 @@
# SPDX-License-Identifier: PMPL-1.0-or-later
# Containerfile — Two-stage build for Svalinn Edge Gateway
# Containerfile — Svalinn Edge Gateway (AffineScript / typed-wasm build)
#
# Stage 1: ReScript compilation (needs node for the rescript compiler + deno)
# Stage 2: Minimal runtime with Deno only (wolfi-base)
# Migration: svalinn's application code moved from ReScript→JS to
# AffineScript→typed-WasmGC (see PR / src/*.affine). The build now needs an
# OCaml toolchain (the affinescript compiler) and a Rust+Idris2 toolchain
# (typed-wasm: the verified cross-language WasmGC ABI), then Deno at runtime
# to host the WASM modules via the @hyperpolymath/affine-js bridge.
#
# Stage A build the affinescript compiler (OCaml / opam / dune)
# Stage B build typed-wasm (Rust / cargo / Idris2 ABI)
# Stage C compile every src/*.affine -> .wasm
# Stage D minimal Deno runtime + host bridge
#
# Upstream tool repos are pinned by commit for reproducibility.

ARG AFFINESCRIPT_REF=d2875a552f1d389b4a60c4adfdc02ae53e36aca3
ARG TYPED_WASM_REF=e90e2d1a307c33d594d54065c902500da327977c

# ---------------------------------------------------------------------------
# Stage 1: Build
# Stage A: build the AffineScript compiler
# ---------------------------------------------------------------------------
FROM cgr.dev/chainguard/wolfi-base:latest AS build
FROM ocaml/opam:debian-12-ocaml-5.1 AS affinescript-build
ARG AFFINESCRIPT_REF
USER root
RUN apt-get update && apt-get install -y --no-install-recommends git m4 \
&& rm -rf /var/lib/apt/lists/*
USER opam
WORKDIR /opt
RUN git clone https://github.com/hyperpolymath/affinescript.git \
&& cd affinescript && git checkout "${AFFINESCRIPT_REF}"
WORKDIR /opt/affinescript
RUN opam install --deps-only -y . \
&& eval "$(opam env)" \
&& dune build --release \
&& cp _build/install/default/bin/affinescript /opt/affinescript-bin

# Install build-time dependencies: deno (runtime) + node (rescript compiler)
RUN apk add --no-cache deno nodejs
# ---------------------------------------------------------------------------
# Stage B: build typed-wasm (verified WasmGC ABI / conventions)
# ---------------------------------------------------------------------------
FROM rust:1-bookworm AS typed-wasm-build
ARG TYPED_WASM_REF
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt
RUN git clone https://github.com/hyperpolymath/typed-wasm.git \
&& cd typed-wasm && git checkout "${TYPED_WASM_REF}"
WORKDIR /opt/typed-wasm
RUN cargo build --release --locked

# ---------------------------------------------------------------------------
# Stage C: compile AffineScript sources to typed-WasmGC
# ---------------------------------------------------------------------------
FROM debian:12-slim AS wasm-build
COPY --from=affinescript-build /opt/affinescript-bin /usr/local/bin/affinescript
COPY --from=typed-wasm-build /opt/typed-wasm/target/release /opt/typed-wasm
ENV TYPED_WASM_HOME=/opt/typed-wasm
WORKDIR /build

# Copy dependency manifests first for layer caching
COPY deno.json deno.lock package.json ./
COPY src/deno.json src/deno.lock src/rescript.json ./src/

# Install JS dependencies (node_modules for rescript compiler)
RUN deno install

# Copy source tree
COPY src/ ./src/
COPY spec/ ./spec/
COPY config/ ./config/

# Compile ReScript to JavaScript via Deno's task runner. `deno task` prepends
# node_modules/.bin/ to PATH (deno.json sets nodeModulesDir=auto), so the
# rescript CLI resolves correctly from the workspace-root node_modules.
RUN deno task res:build
RUN mkdir -p dist/wasm \
&& find src -name '*.affine' -print0 | while IFS= read -r -d '' f; do \
base="$(basename "$f" .affine)"; \
echo "compiling $f -> dist/wasm/${base}.wasm"; \
affinescript compile "$f" -o "dist/wasm/${base}.wasm"; \
done

# ---------------------------------------------------------------------------
# Stage 2: Runtime
# Stage D: runtime (Deno hosts the WASM modules)
# ---------------------------------------------------------------------------
FROM cgr.dev/chainguard/wolfi-base:latest AS runtime

RUN apk add --no-cache deno

# Non-root user for defence in depth
RUN adduser -D -u 1000 svalinn
USER svalinn

WORKDIR /app

# Copy compiled .res.js files and runtime config
COPY --from=build --chown=svalinn:svalinn /build/src/ ./src/
COPY --from=build --chown=svalinn:svalinn /build/deno.json ./deno.json
COPY --from=build --chown=svalinn:svalinn /build/deno.lock ./deno.lock
COPY --from=build --chown=svalinn:svalinn /build/node_modules/ ./node_modules/
COPY --from=build --chown=svalinn:svalinn /build/spec/ ./spec/
COPY --from=build --chown=svalinn:svalinn /build/config/ ./config/
COPY --from=wasm-build --chown=svalinn:svalinn /build/dist/ ./dist/
COPY --chown=svalinn:svalinn src/host/ ./src/host/
COPY --chown=svalinn:svalinn deno.json deno.lock ./
COPY --chown=svalinn:svalinn spec/ ./spec/
COPY --chown=svalinn:svalinn config/ ./config/

# Expose the default gateway port
EXPOSE 8000

# Health check against the /healthz endpoint
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD deno eval "const r = await fetch('http://localhost:8000/healthz'); Deno.exit(r.ok ? 0 : 1)"

# Run with minimal Deno permissions
ENTRYPOINT ["deno", "run", \
"--allow-net", \
"--allow-env", \
"--allow-read", \
"src/Main.res.js"]
"src/host/affine_host.js"]
Loading
Loading