effx
Specifications

Spec 0001 — Compiler kernel (`@effx/ir`, `@effx/compiler`)

frozen 2026-10-03. Changes require editing this file explicitly. Source: docs/research/2026-10-02-deep-research-report.md §"Compiler and IR architecture", §"Research suite", §"Roadmap" (gate 1).

Status: frozen 2026-10-03. Changes require editing this file explicitly. Source: docs/research/2026-10-02-deep-research-report.md §"Compiler and IR architecture", §"Research suite", §"Roadmap" (gate 1).

Goal

One vertical proof that annotations become a Schema-defined IR, that the IR has stable laws (round trip, normalization, canonicalization, graph projection, determinism, syntax equivalence), and that extensions interpret annotations / analyse the graph / generate ordinary Effect without touching the core.

Non-goals (other slices): TypeScript frontend, runtime decorators, Cedar, persistence, CLI binary.

Journey

Collected (frontend-neutral declarations + annotations)
   │ interpret: every Extension.interpreters[annotation.name]
   ▼
Contribution* ──merge──▶ ApplicationIR ──normalize──▶ IR₀
                                                      │ toGraph
                                                      ▼
                           analyses(IR₀, GraphIndex) ──▶ Diagnostic[]   (errors block generation)
                                                      │
                           generators(IR₀, GraphIndex) ──▶ GeneratedFile[]

IR vocabulary (@effx/ir)

StableId

Branded string, grammar (one regex, no spaces):

StableId   := kind ":" name
kind       := [a-z][a-z0-9-]*
name       := [A-Za-z0-9_$] [A-Za-z0-9_$./:-]*
kindexamplemeaning
schemaschema:users/GetUserInputa runtime Effect Schema symbol
modelmodel:Userpersistent model
serviceservice:UsersEffect Context service
operationoperation:User.GetQuery/Command
capabilitycapability:User.ChangeEmailauthority kind
focusfocus:User.emailmodel root + path
exposureexposure:http:User.Gettransport exposure (name may contain : sub-kind)
extext:<extension>/<name>extension-owned node

Helpers: StableId.make(kind, name), kindOf, nameOf.

References (no source locations, no ts.*)

TypeFields
SchemaRefmodule: string, export: string, symbolId: StableId(schema:…)
SymbolRefmodule: string, export: string, member?: string

Nodes (closed tagged union on _tag, plus one escape)

_tagFields
Schemaid, ref: SchemaRef
Modelid, name, schema: SchemaRef, table?: string, views: [{ name, schema: SchemaRef }] (set)
Serviceid, name, symbol: SymbolRef
Operationid, name, kind: "Query"|"Command", input: SchemaRef, success: SchemaRef, errors: { values: SchemaRef[] (set), inferred }, requirements: { values: StableId[] (set), inferred }, handler: SymbolRef
Capabilityid, name, resource: StableId(model), focus?: StableId(focus)
Focusid, root: StableId(model), path: string[] (ordered)
Exposureid, operation: StableId, transport: { _tag:"http", method, path } | { _tag:"rpc", name } | { _tag:"cli", command: string[] }
Extensionid, extension: string, tag: string, data: Json

Decision: core Exposure.transport is a closed union (http/rpc/cli) because core analyses (e.g. "Query exposed via non-GET") must pattern-match exhaustively. Third-party transports and any other new concept use Extension nodes (ext: ids) — they survive normalization, canonical JSON and graph projection without a core schema change. Promotion into the core union is a versioned IR migration.

Edges

{ kind, from: StableId, to: StableId, qualifier?: string } with kind ∈ { InputOf, SuccessOf, ErrorOf, Requires, AuthorizedBy, Focuses, ExposedAs, PersistsAs, ViewOf }.

kindfrom → toqualifier
InputOf / SuccessOf / ErrorOfschema: → operation:—
Requiresoperation: → service:—
AuthorizedByoperation: → capability:—
Focusescapability: → focus:—
ExposedAsoperation: → exposure:transport tag
PersistsAsmodel: → schema:table name
ViewOfschema: → model:view name

Edges may point at absent nodes; that is data (missingTargets), not a decode failure.

Envelope

ApplicationIR = { format: "effx-ir", version: 1, nodes: Node[], edges: Edge[] }, an Effect Schema. Codec, Arbitrary and type are derived from it.

Normalization (normalize)

WhatRule
nodessorted by id, then by canonical encoding; exact duplicates removed
edgessorted by (kind, from, to, qualifier ?? ""), then canonical encoding; exact duplicates removed
set fieldsModel.views, Operation.errors.values, Operation.requirements.values: sorted by canonical element encoding, deduped
ordered fieldsFocus.path, Exposure.transport.command untouched
idempotentnormalize(normalize(x)) == normalize(x)

Duplicate ids with different content are preserved (reported by analysis EFFX1001).

Canonical JSON (canonical)

RFC 8785 over encode(normalize(ir)): object keys sorted by UTF-16 code units, no whitespace, primitives via ES6 JSON.stringify semantics (strings, finite numbers, booleans, null). semanticHash(ir) = hex(sha256(utf8(canonical(ir)))) via the Effect Crypto service (provided by BunCrypto.layer at composition roots). encode/decode are Schema-derived (Schema.toCodecJson, Schema.fromJsonString).

Graph index (graph)

toGraph(ir): GraphIndex { graph: Graph<Node, Edge>, byId: HashMap<StableId, Node>, ids: Trie<StableId>, dangling: Edge[], duplicates: StableId[] }. fromGraph(index): ApplicationIR (normalized). Helpers: outgoing(index, id, kind?), reachable(index, id), cycles(index), missingTargets(index), withPrefix(index, prefix).

Arbitrary adapter (arbitrary)

arbitraryIR() wraps effect/Arbitrary (unstable) and is the only module importing it.

Compiler contract (@effx/compiler)

Data

TypeShape
Diagnostic{ code: "EFFX####", severity: "error"|"warning"|"info", message, location?: {file,line,col}, related?: Diagnostic[] } (Schema)
StageResult<A>{ value: Option<A>, diagnostics: Diagnostic[] }
CompilerFaultSchema.TaggedError — IO / invariant breakage only
AnnotationArgstring | number | boolean | SymbolRef | SchemaRef | record | array | { _tag: "Lambda" }
Annotation{ name: string, args: AnnotationArg[] }
TypeRefSchema(SchemaRef) | Service(StableId) | Opaque{ display }
Declaration{ id: string, kind: "class"|"staticMethod"|"builder"|"model", module, export, member?, annotations, handlerSignature? }
Collected{ declarations: Declaration[], diagnostics: Diagnostic[] } (frontend diagnostics travel as data; revised by spec 0002)

Services and extensions

SourceFrontend.analyze(project: ProjectConfig) => Effect<Collected, CompilerFault>

Extension = { name, interpreters: Record<annotationName, Interpreter>, analyses: Analysis[], generators: Generator[] }
Interpreter(annotation, declaration, ctx) => Contribution { nodes, edges, diagnostics }
Analysis(ir, index) => Diagnostic[]
Generator(ir, index) => Effect<GeneratedFile[], CompilerFault>

Built-in extensions: core (Query, Command, Errors, Requirements, PersistentModel, Authorize), http (Http.Get/Post/Put/Patch/Delete), rpc (Rpc), cli (Cli), client (generator only). SchemaRef.module / SymbolRef.module are import specifiers the frontend guarantees resolvable from the generated directory; generators emit them verbatim.

Annotation argument shapes (what both decorators and builders lower to)

Annotationargs
Query / Command[{ name?: string, input: Schema, success: Schema }]; operation name defaults to <export>.<member>
Errors[Schema, …]
Requirements[Symbol, …] (service classes; id = service:<export>)
PersistentModel[{ table, schema: Schema, views?: { [name]: Schema }, focus?: { [name]: string[] } }]; model id = model:<export>
Authorize[{ name, resource: <model name>, focus?: string[] }]
Http.<Method>[path]
Rpc[name]
Cli["words separated by spaces"]

Schema = { _tag: "Schema", ref: SchemaRef }, Symbol = { _tag: "Symbol", ref: SymbolRef }. The Query/Command interpreter owns the Operation node and its edges and reads sibling Errors/Requirements annotations for declared sets; the Errors/Requirements interpreters only compare declared vs inferred (they need handlerSignature, which is not in the IR), so EFFX22xx/23xx are produced during interpret, not analyze.

Diagnostic codes

CodeSeverityMeaning
EFFX1001errorduplicate StableId with differing content
EFFX1002erroredge references a missing node
EFFX1101errorannotation has no interpreter in any extension
EFFX1102errorannotation arguments malformed
EFFX1103errortransport annotation on a declaration that is not an operation
EFFX2201errorhandler error not declared in @Errors
EFFX2202error@Errors declares an error the handler cannot fail with
EFFX2302errorhandler requirement not declared in @Requirements
EFFX2303error@Requirements declares a stale requirement
EFFX2401errorQuery exposed over non-GET HTTP
EFFX2203errorinferred error constituent is not schema-addressable
EFFX2204warningoperation infers no schema-addressable errors
EFFX2304errorinferred requirement has no stable service identity
EFFX0001infofrontend TypeScript version differs from the project pin (ADR 0009)

Pipeline stages

collect → interpret → merge → normalize → analyze → generate; every stage returns StageResult; diagnostics accumulate; any error severity → generate is skipped (value: none).

Laws (tests)

LawTest
round tripdecode(encode(normalize(x))) == normalize(x)
canonical idempotencecanonical(decode(canonical(x))) == canonical(x)
normalize idempotencenormalize(normalize(x)) == normalize(x)
graph projectionfromGraph(toGraph(x)) == normalize(x)
determinismshuffling nodes/edges/set members leaves semanticHash unchanged
syntax equivalencedecorator-style and builder-style Collected yield identical canonical IR
diagnosticsEFFX2201/2303 fire on declared≠inferred; EFFX2401 on Query+POST
generator determinismsame IR → byte-identical files

Definition of done

  • bun run typecheck, bun run lint, bun run fmt:check, bun --bun vitest run green.
  • User slice fixture compiles to the expected IR and generates http.ts, rpc.ts, cli.ts, client.ts.
  • All laws above are executable tests (property-based where Arbitrary derives).

On this page