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
effx buildadditionally 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.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.
endpointisendpointKey(item)(the exactHttpApiEndpointidentifier),operationIdismetadata.operationIdwhen declared, else<group>.<endpoint>;bindingislocaliff the operation has ahandler; a group isexternaliff every item is external (the generators' ownexternalGroupsrule);wiringisgroupApiHandlersName(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|alland for any output directory (the surface never readsGenerationContext), exactly likesemanticHash. - No
ts.*, no locations, no Schema ASTs (invariants 5, 10).module#exportstrings come from IRSymbolRefs. - Mixed groups: a group is
externaliff every item is external; any local item makes itlocal(the generators'externalGroupsrule). 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):
AppRoutesin thehttp.tsfile (all-mode, local operations/RPC; it mounts every local root, group and/rpc), and<Group>ApiHandlersin 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 locatingexport 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 contractX 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", asexamples/users/src/server.ts) counts at the use site; the re-export statement itself,import type, and uses inside type positions (typeof AppRoutesin 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
tscto 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
alchemyneeded — it need not be installed): at least one call in the reachable files isCloudflare.Worker(…),Worker(…)bound to a named/namespace import from"alchemy/Cloudflare",Cloudflare.Worker<…>()(…)(theclass 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 recognizedmain: "./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 accessns[name]on a namespace import of a generated module. The check still reports what it can establish. - Reachability excludes
node_modules,.d.tsand 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)
| Code | Severity | Meaning |
|---|---|---|
| EFFX2801 | error | --against entry is not (and does not reach) a recognized Alchemy Worker program |
| EFFX2802 | error | missing wiring: surface requires <export> (from <file>), program never references it |
| EFFX2803 | error | extra wiring: program references generated <export>/<module> the current build does not produce |
| EFFX2804 | error | --against file cannot be read/parsed |
| EFFX2805 | warning | .effx/surface.json differs from the recomputed surface (stale) |
| EFFX2806 | warning | wiring not statically decidable at this site |
| EFFX2807 | info | the 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
- Determinism & mode-independence:
surfaceOfbytes identical acrossemitmodes and output dirs forexamples/usersand the rc.116 Profile/Directory fixtures;semanticHashfield equals manifestsemanticHash; Schema round-tripdecode(encode(x)) = x; a mutation of one endpoint path changes exactly the surface bytes for that endpoint. - Content: users surface lists
User.Get/User.ChangeEmailHTTP endpoints with method/path/operationId/security/access fields, the RPC and CLI names,binding: "local"; the rc.116 Profile surface listsexternalgroups withwiring: "<Group>ApiHandlers"anddecisionTime. - Check, positive: a minimal Alchemy Worker fixture (
Cloudflare.Workerin theclass … extendsform importingAppRoutesthrough a barrel) againstexamples/userspasses (exit 0). The fixture is parsed, not typechecked or executed; noalchemyinstall, no network. - Check, negative (each asserts code + exit 1): Worker without
AppRoutes→ 2802; Worker referencing a deleted generated module → 2803; non-Worker entry → 2801;AppRoutesonly in a type position → 2802; a Worker form withmain:literal pointing at the impl file passes; namespace + computed access → 2806 warning; stalesurface.json→ 2805. - 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. - 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.
Spec 0020 — User-declared annotations: one typed definition, derived on every side
approved and frozen 2026-10-04 (decisions in §0). Implementation follows spec 0015's merge; v1 scope is target: "operation" for user definitions (§0.4). Change this contract by editing this file expl…
Spec 0022 — Persistence ports and the adapter conformance suite
spec-frozen 2026-10-04 (operator-approved from docs/research/effect-fullstack-composition.md §6). Implementation starts only after spec 0020 (Annotation.define) merges to main; until then this file i…