effx
Specifications

Spec 0021 — Surface manifest and `effx surface check`

spec-frozen 2026-10-04 (operator-approved from docs/research/effect-fullstack-composition.md §2/§4 item 4). Builds on 0001, 0003, 0005, 0006, 0010. Orthogonal to 0015 (config) and 0020 (annotation de…

Status: spec-frozen 2026-10-04 (operator-approved from docs/research/effect-fullstack-composition.md §2/§4 item 4). Builds on 0001, 0003, 0005, 0006, 0010. Orthogonal to 0015 (config) and 0020 (annotation definitions); touches none of their files (packages/runtime/src, frontend-ts/src/{lower,collect}.ts, compiler/src/extensions/*).

1. Outcome

  1. effx build additionally writes .effx/surface.json: a deterministic, Schema-defined projection of everything the application exposes — HTTP endpoints per root/group, RPC names, CLI commands, Foldkit commands, local-vs-external bindings — derived only from the IR (plus the generators' own naming functions). No source locations, no emit mode, no output paths.
  2. effx surface check --against <entry.ts> [--emit <mode>] [--project …] is an opt-in command that verifies a deployment program (an Alchemy Worker program) wires exactly the generated handlers the surface requires: no missing, no extra. It never deploys, never calls Cloudflare, never executes the program (static analysis only).

Why: the Alchemy plan is the evaluated Stack Effect, so effx cannot and should not derive infrastructure (research §2 F1). What it can catch is IR↔deployment drift: a group exists in the IR but no Worker mounts its handlers, or the Worker still mounts a generated module the IR no longer produces. This is the "test rung" of an effx infra projection (AGENTS.md: generate > test > convention).

Non-goals: generating Stack/resources/Worker entries; checking routes served by hand-written code; checking runtime behaviour, bindings, domains, secrets or Layers; Cron/Queue wiring (no IR concept yet — the manifest reserves no field for them); Cedar.

2. .effx/surface.json

Written next to ir.json as canonical JSON (RFC 8785 via canonicalJson) + "\n". Surface is an Effect Schema exported from @effx/compiler (surfaceOf, Surface, SurfaceJson); the file is Schema.toCodecJson(Surface) output.

Surface {
  format: "effx-surface", version: 1,
  semanticHash: string,                       // the IR's sha-256 (ADR 0003): links file ↔ IR
  operations: [{ name, kind: "Query"|"Command", binding: "local"|"external" }],   // sorted by name
  http: [{                                     // sorted by root, group (same order as generators)
    root, group, title?, binding: "local"|"external",
    wiring: string,                            // the generated export a deployment references (§4.2)
    endpoints: [{                              // sorted by (path, method), then endpoint
      operation, endpoint, operationId, method, path, status?, conditional,
      middleware: ["module#export", …], security: ["module#export", …],
      access?: { exposure: "External"|"Internal", acceptedCredentials, principalKinds,
                 decisionTime: "SnapshotRead"|"Transaction",
                 capabilities: <tagged None|One|Any|All>, concealment: <tagged Reveal|NotFound> }
    }]
  }],
  rpc: [{ name, operation, wiring: "AppRoutes" }],                     // sorted by name
  cli: [{ command: string[], operation }],                             // sorted by command
  foldkit: [{ operation }]                                             // sorted by operation
}

Rules:

  • Derivation, not duplication. endpoint is endpointKey(item) (the exact HttpApiEndpoint identifier), operationId is metadata.operationId when declared, else <group>.<endpoint>; binding is local iff the operation has a handler; a group is external iff every item is external (the generators' own externalGroups rule); wiring is groupApiHandlersName(group) for an external group and "AppRoutes" for a local one. These come from the generators' exported helpers (httpItems, httpGroups, endpointKey, groupApiHandlersName), never re-typed strings.
  • Mode-independent. Identical bytes for --emit contract|handlers|all and for any output directory (the surface never reads GenerationContext), exactly like semanticHash.
  • No ts.*, no locations, no Schema ASTs (invariants 5, 10). module#export strings come from IR SymbolRefs.
  • Mixed groups: a group is external iff every item is external; any local item makes it local (the generators' externalGroups rule). The surface adds no new analysis.
  • A project with no exposures writes a valid empty surface.

3. effx build integration

After ir.json, build writes .effx/surface.json (one added call; the "wrote …" line lists it). It is not in manifest generated (that list owns .ts files only), and build on errors still writes nothing. The IR/hash/generated bytes of every existing fixture stay byte-identical.

4. effx surface check

4.1 Inputs

--against <file> (required, one TypeScript file: the Alchemy program or Worker module), --project (shared flag), --emit (default all, same meaning as build). The command compiles the project in memory (never trusts disk), takes the generated file set from the pipeline's files, and recomputes the surface. If .effx/surface.json exists and differs from the recomputed bytes it reports EFFX2805 (warning: run effx build).

4.2 What "wires" means

Studied from Alchemy 2.0.0-beta.80 (alchemy-run/alchemy ea384f0, packages/alchemy/src/Cloudflare/Workers/Worker.ts:1453-1700, Workers/HttpServer.ts:16): a Worker's fetch is an HttpEffect (or Effect<HttpEffect>) returned from the Worker's construction Effect; the router that backs it is built at runtime, so served routes are not statically enumerable from the program. effx therefore defines wiring over the only thing static analysis can establish and the generators control:

A program wires a generated wiring export W iff some value reference to W occurs in a source file reachable from the entry through TypeScript's module graph (static and import() literal specifiers, main: "<literal path>" of a recognized Worker call, re-export chains followed).

  • Wiring exports (closed set, from the generated files themselves): AppRoutes in the http.ts file (all-mode, local operations/RPC; it mounts every local root, group and /rpc), and <Group>ApiHandlers in each external group's *-handlers.ts (handlers/all mode). Contract, rpc, cli, client, foldkit and guard files are not wiring exports. File → export mapping is established by locating export const <Name> = in the in-memory generated file contents; the same name exported by two files (the same group id under two roots, spec 0013) requires both to be wired.
  • Expected set X = wiring exports named by the surface (http[].wiring, rpc[].wiring) that exist in the current generated file set. In --emit contract X is empty and the command reports info EFFX2807.
  • Referenced set Y = wiring exports (incl. exports of generated modules not in the current output, i.e. stale files) with a value reference. A reference through a barrel (export { AppRoutes } from "../.effx/generated/http.ts", as examples/users/src/server.ts) counts at the use site; the re-export statement itself, import type, and uses inside type positions (typeof AppRoutes in a type) do not.
  • missing = X ∖ Y (EFFX2802, error). extra = Y ∖ X (EFFX2803, error): a reference to a generated module/export the current build no longer produces. Group granularity is exact for routes: generated code is checked by tsc to handle every endpoint of a group (handlers.handleAll), so wiring a group wires all its routes. Hand-written routes mounted beside generated ones are allowed and invisible to the check.
  • Worker recognition (static, by import specifier, no resolution of alchemy needed — it need not be installed): at least one call in the reachable files is Cloudflare.Worker(…), Worker(…) bound to a named/namespace import from "alchemy/Cloudflare", Cloudflare.Worker<…>()(…) (the class X extends … form) or a class extending that call (whose .make(…) impl may live in another file). Otherwise EFFX2801 (error): the entry is not a recognized Alchemy Worker program. A recognized main: "./path.ts" literal adds that file as a root (async Worker form).
  • Not statically decidable → warning, not silence (EFFX2806): import(expr) with a non-literal specifier, or element access ns[name] on a namespace import of a generated module. The check still reports what it can establish.
  • Reachability excludes node_modules, .d.ts and the generated output itself (references into it are the facts); it follows project-local files only.

4.3 Diagnostics (plain codes; range EFFX28xx reserved for later registration in the 0016 registry — 0016 has not landed)

CodeSeverityMeaning
EFFX2801error--against entry is not (and does not reach) a recognized Alchemy Worker program
EFFX2802errormissing wiring: surface requires <export> (from <file>), program never references it
EFFX2803errorextra wiring: program references generated <export>/<module> the current build does not produce
EFFX2804error--against file cannot be read/parsed
EFFX2805warning.effx/surface.json differs from the recomputed surface (stale)
EFFX2806warningwiring not statically decidable at this site
EFFX2807infothe selected emit mode generates no wiring exports

Output through the existing report formatter; exit 1 iff any error. Pipeline errors fail first, as check.

5. Files

New: packages/compiler/src/surface.ts (+ surface-check.ts: pure comparison), packages/frontend-ts/src/wiring.ts (the only module touching ts for this; returns plain data), packages/cli/src/surface.ts (write + command). Existing files, minimal additive edits only: compiler/src/index.ts, frontend-ts/src/index.ts, cli/src/{index,main,commands}.ts (one call in build, one subcommand), STATE.md, AGENTS.md package notes. The 0015 curated root export of @effx/compiler must include surfaceOf/Surface/SurfaceJson when it lands.

Open follow-up (not done here): generate/http.ts is outside this slice's ownership; surface derivation uses its exported helpers and locates files by export name, so no file-name logic is duplicated. When the generators become term-producing (0019 S2), wiring can be read from the produced terms.

6. Falsifiers / acceptance

  1. Determinism & mode-independence: surfaceOf bytes identical across emit modes and output dirs for examples/users and the rc.116 Profile/Directory fixtures; semanticHash field equals manifest semanticHash; Schema round-trip decode(encode(x)) = x; a mutation of one endpoint path changes exactly the surface bytes for that endpoint.
  2. Content: users surface lists User.Get/User.ChangeEmail HTTP endpoints with method/path/operationId/security/access fields, the RPC and CLI names, binding: "local"; the rc.116 Profile surface lists external groups with wiring: "<Group>ApiHandlers" and decisionTime.
  3. Check, positive: a minimal Alchemy Worker fixture (Cloudflare.Worker in the class … extends form importing AppRoutes through a barrel) against examples/users passes (exit 0). The fixture is parsed, not typechecked or executed; no alchemy install, no network.
  4. Check, negative (each asserts code + exit 1): Worker without AppRoutes → 2802; Worker referencing a deleted generated module → 2803; non-Worker entry → 2801; AppRoutes only in a type position → 2802; a Worker form with main: literal pointing at the impl file passes; namespace + computed access → 2806 warning; stale surface.json → 2805.
  5. External groups: the rc.116 contract/handlers twin: handlers-mode project requires ProfileApiHandlers; a program wiring it passes, omitting it fails 2802; contract mode yields 2807.
  6. Gates on a clean committed worktree: focused tests, bun run check, effect:diagnostics, rc.116 fixture typecheck; all existing generated bytes/IR/hashes unchanged; ff-merge main; pack the exact commit; remove clean worktree/branch. No deploy, no Cloudflare call, no network.

On this page