Skip to content

RFC: Headless server-side evaluation + multi-language verification libraries #77

Description

@horner

RFC: Headless server-side evaluation + multi-language verification libraries

Status: Proposal · Area: @esheet/core, new language ports · Type: Feature / architecture

Summary

eSheet already renders and evaluates forms client-side. This RFC proposes a small, headless evaluation surface — first formalized in TypeScript on top of @esheet/core, then ported as native libraries to Go, Python, Rust, Java, and C# — so that any backend, in any language, can re-validate a submission and recompute every derived value against a canonical sheet the user cannot tamper with.

The docs already say it out loud: expressions are evaluated client-side, so they must not be trusted for anything security-sensitive — always validate on the server. Today there is no first-class way to do that off the browser. This closes that gap.

Motivation

A rendered form is a UX convenience, not a source of truth. The browser can be bypassed, the payload can be edited, and any "computed" field (a BMI, a risk score, a total) can be overwritten before it reaches your API. The server must be able to:

  1. Load the canonical sheet from its own copy — never from the client payload.
  2. Bind the untrusted answers that arrived from the client.
  3. Re-run validation (visibility, required-ness, conditional rules).
  4. Recompute every expression-derived field and compare it against what the client stored.

@esheet/core is framework-agnostic with no DOM dependency, so it already runs in Node. The logic functions take (definition, answers) as separate arguments — the trust boundary we want is already baked into the API shape. This RFC wraps that into a tiny, stable surface and replicates it across languages.

Canonical entry point per language

Language Import Entry call
TypeScript import { eSheet } from '@esheet/core' eSheet.load('form.yml')
Python import esheet esheet.load('form.yml')
Rust use esheet; esheet::load("form.yml")
Go import ".../esheet" esheet.Load("form.yml")
Java import org.esheet.Sheet; Sheet.load("form.yml")
C# using ESheet; Sheet.Load("form.yml")

In module-function languages (TS, Python, Rust, Go) the brand is the callsite — you call esheet.load. In class languages (Java, C#) the brand lives in the package/namespace and the entry type is Sheet — you call Sheet.load. This split is deliberate: in C# a type and its enclosing namespace cannot share a name, so an ESheet namespace + ESheet class collides. Keeping the namespace ESheet and the entry type Sheet is collision-free, drops MieWeb, and keeps Java and C# parallel.

Minimal API surface

Five operations cover the 90% case. The sheet and the answers are always separate inputs.

Operation Purpose
load(source) Load the canonical, trusted sheet (path, string, or object).
sheet.withAnswers(source) Bind untrusted client answers, returning a filled sheet.
sheet.evaluate() Run validation: visibility, required, conditional rules → { valid, errors }.
sheet.value(id) Read the stored answer (what the client submitted).
sheet.compute(id) Recompute a value from the canonical expression, ignoring the stored value.

withAnswers is the explicit, discoverable name. fill is available as a terser alias that matches the renderer's own "fill-out" vocabulary — pick one as canonical before the ports land.

Reference usage (TypeScript)

import { eSheet, approxEqual } from '@esheet/core';

// 1. Canonical sheet from the server's own copy — the client never supplies this.
const sheet = eSheet.load('form.yml');

// 2. Bind the untrusted answers the client submitted.
const filled = sheet.withAnswers('answers.yml');

// 3. Validate visibility, required-ness, and conditional rules.
const result = filled.evaluate();
if (!result.valid) {
  throw new Error(`invalid submission: ${result.errors.join(', ')}`);
}

// 4. Compare what the client *stored* against what the server *recomputes*.
const storedBmi = filled.value('BMI');     // what the client sent
const computedBmi = filled.compute('BMI'); // recomputed from {weight}/(({height}/100)^2)

if (!approxEqual(storedBmi, computedBmi, 1e-6)) {
  console.warn(`BMI mismatch — client sent ${storedBmi}, server computed ${computedBmi}`);
}

Why a tolerance, not !=compute('BMI') is floating-point division. A raw != will report phantom mismatches across languages even when both sides are correct. Equality of computed numerics is defined by an epsilon (or a canonical decimal rounding), never bitwise.

MVP examples in every target language

The same five-call flow, idiomatic to each language. These are the conformance targets for the ports.

Go

package main

import (
	"fmt"
	"log"

	"go.esheet.org/esheet"
)

func main() {
	// Canonical sheet from the server — the client never supplies this.
	sheet, err := esheet.Load("form.yml")
	if err != nil {
		log.Fatal(err)
	}

	// Bind untrusted answers submitted by the client.
	filled, err := sheet.WithAnswers("answers.yml")
	if err != nil {
		log.Fatal(err)
	}

	// Validate visibility, required, and conditional rules.
	result := filled.Evaluate()
	if !result.Valid {
		log.Fatalf("invalid submission: %v", result.Errors)
	}

	storedBMI := filled.Value("BMI")       // what the client sent
	computedBMI := filled.Compute("BMI")   // recomputed server-side

	if !esheet.ApproxEqual(storedBMI.Float(), computedBMI.Float(), 1e-6) {
		fmt.Printf("BMI mismatch: client=%v server=%v\n", storedBMI, computedBMI)
	}
}

Python

import math
import esheet

# Canonical sheet from the server — never from the client payload.
sheet = esheet.load("form.yml")

# Bind untrusted answers submitted by the client.
filled = sheet.with_answers("answers.yml")

# Validate visibility, required, and conditional rules.
result = filled.evaluate()
if not result.valid:
    raise ValueError(f"invalid submission: {result.errors}")

stored_bmi = filled.value("BMI")      # what the client sent
computed_bmi = filled.compute("BMI")  # recomputed server-side

if not math.isclose(stored_bmi, computed_bmi, rel_tol=1e-6):
    print(f"BMI mismatch: client={stored_bmi} server={computed_bmi}")

Rust

use esheet::{self, ApproxEqual, Error};

fn main() -> Result<(), Error> {
    // Canonical sheet from the server — never from the client payload.
    let sheet = esheet::load("form.yml")?;

    // Bind untrusted answers submitted by the client.
    let filled = sheet.with_answers("answers.yml")?;

    // Validate visibility, required, and conditional rules.
    let result = filled.evaluate();
    if !result.valid {
        return Err(Error::Invalid(result.errors));
    }

    let stored_bmi = filled.value("BMI");     // what the client sent
    let computed_bmi = filled.compute("BMI"); // recomputed server-side

    if !stored_bmi.approx_eq(&computed_bmi, 1e-6) {
        println!("BMI mismatch: client={stored_bmi:?} server={computed_bmi:?}");
    }
    Ok(())
}

Java

import org.esheet.Sheet;
import org.esheet.ValidationResult;

public class Verify {
    public static void main(String[] args) throws Exception {
        // Canonical sheet from the server — never from the client payload.
        Sheet sheet = Sheet.load("form.yml");

        // Bind untrusted answers submitted by the client.
        Sheet filled = sheet.withAnswers("answers.yml");

        // Validate visibility, required, and conditional rules.
        ValidationResult result = filled.evaluate();
        if (!result.isValid()) {
            throw new IllegalStateException("invalid submission: " + result.getErrors());
        }

        double storedBmi = filled.value("BMI").asDouble();     // what the client sent
        double computedBmi = filled.compute("BMI").asDouble(); // recomputed server-side

        if (Math.abs(storedBmi - computedBmi) > 1e-6) {
            System.out.printf("BMI mismatch: client=%f server=%f%n", storedBmi, computedBmi);
        }
    }
}

C#

using ESheet;

// Canonical sheet from the server — never from the client payload.
var sheet = Sheet.Load("form.yml");

// Bind untrusted answers submitted by the client.
var filled = sheet.WithAnswers("answers.yml");

// Validate visibility, required, and conditional rules.
var result = filled.Evaluate();
if (!result.Valid)
    throw new InvalidOperationException($"invalid submission: {result.Errors}");

double storedBmi = filled.Value("BMI").AsDouble();     // what the client sent
double computedBmi = filled.Compute("BMI").AsDouble(); // recomputed server-side

if (Math.Abs(storedBmi - computedBmi) > 1e-6)
    Console.WriteLine($"BMI mismatch: client={storedBmi} server={computedBmi}");

Why eSheet — for any developer, in any language

One schema, every runtime. Author a sheet once in the portable mieforms-v1.0 schema. Render it with the React builder/renderer in the browser, and validate or evaluate the exact same schema natively in your backend. You stop reimplementing form logic per stack.

The trust boundary is the design, not an afterthought. The sheet is the canonical contract held by your server; answers arrive untrusted and separate. Every computed value is re-derived and every rule re-checked server-side. Tamper detection isn't a feature you bolt on — it falls out of the API shape.

The schema is the single source of truth. Conditional visibility, required-ness, computed fields, and validation all live in the schema — not scattered across frontend and backend code that silently drift apart. Change the sheet once; every runtime follows.

Identical behavior, guaranteed. Every language port passes the same conformance corpus, so compute('BMI') returns the same result (within epsilon) in Go, Rust, Java, C#, Python, and TypeScript. A sheet behaves the same everywhere it runs.

Zero lock-in. @esheet/core is framework-agnostic with no DOM dependency and no required backend. Embed the React UI if you want it; ignore it entirely and consume just the schema if you don't. Open packages on npm — and soon on PyPI, crates.io, Maven Central, NuGet, and Go modules.

Tiny, consistent surface. Five calls — load, withAnswers, evaluate, value, compute — cover the overwhelming majority of server-side use, and read the same in every language. Learn it once, use it everywhere.

Scope

In scope (MVP)

  • A headless TS server surface over existing @esheet/core functions (no new evaluation logic).
  • The five-call API in Go, Python, Rust, Java, and C#.
  • A shared, language-neutral conformance corpus that every implementation — TS included — must pass in CI.

Out of scope (for now)

  • Re-implementing the visual builder/renderer outside the browser.
  • Persistence, transport, or auth — this is a pure evaluation library.
  • Swift (tracked separately; same surface, added after the first five ship).

The critical dependency: a conformance corpus

The hard part isn't parsing the expression grammar (a Pratt parser is ~200–400 lines per language). It's semantics — specifically how values coerce and compare. If the TS reference leans on JavaScript's loose equality and string↔number coercion (e.g. via new Function), those rules cannot be faithfully reproduced in Rust or Go without reimplementing a slice of ECMAScript.

Decision needed before any port: pin expression semantics deliberately. Strict, typed comparison is far easier to port faithfully and is almost certainly what verification logic should use anyway. If core currently relies on JS-loose semantics, change it at the source rather than enshrining it across six implementations.

Highest-leverage first deliverable: a language-neutral corpus of { definition, responses, target, expected } cases — arithmetic, operator precedence, missing/empty fields, == vs === on "18" vs 18, .length on arrays vs strings, division by zero, decimal rounding. Every implementation passes the identical corpus. That corpus, not "whatever TS happens to do," becomes the spec.

Naming & branding conventions

These rules govern every package so the API reads the same everywhere.

  1. One brand token, cased to each language. esheet for lowercase package/module/namespace IDs, eSheet for the display mark and the JS callable, ESheet only where a language requires an UpperCamel identifier (Java/C# packages and namespaces — eSheet would violate their style).
  2. The public noun is Sheet, never Form. load returns a Sheet. The serialized schema type stays FormDefinition internally, but nothing named "Form" appears in the surface.
  3. No get / loadX verbosity. Verbs are load, withAnswers, evaluate, value, compute. Dropping the get prefix is also more idiomatic for Go (Effective Go forbids it) and C#.

Proposed milestones

  1. Spec + corpus — write the expression semantics doc and the conformance corpus. (Blocks everything.)
  2. TS server surface — thin facade over @esheet/core, passes the corpus. Reference implementation.
  3. First port (Go or Rust) — proves the corpus is language-neutral and the surface is idiomatic.
  4. Remaining ports — Python, Java, C# against the same corpus.
  5. Swift — same surface, fast-follow.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions