effx
Authoring

HTTP

HTTP verbs, groups, group defaults, .in association and .declare() external operations.

HTTP exposure is one annotation per verb. Request and response shape is a separate Http.Contract. Shared values live on a group.

Verbs

DecoratorBuilder stepSignatureRecords
@Http.Get(path).http.get(path)(path: string)an Exposure with transport http, method GET
@Http.Post(path).http.post(path)(path: string)method POST
@Http.Put(path).http.put(path)(path: string)method PUT
@Http.Patch(path).http.patch(path)(path: string)method PATCH
@Http.Delete(path).http.delete(path)(path: string)method DELETE

Source: packages/runtime/src/decorators.ts, packages/runtime/src/builder.ts, packages/compiler/src/extensions/transports.ts.

A @Query exposed over any method except GET is EFFX2401. The one exception is POST with an explicit payloadIsQuery contract (ADR 0010; see HTTP contract).

Without Http.Contract or a group, an operation keeps the default root effx and group operations (spec 0005 §Generation and compatibility).

Groups

A group is source metadata. It does not depend on the operations that name it, and it produces one HttpGroup node group:<root>/<group>.

HttpGroupOptions

FieldTypeOptionalMeaning
rootstring or { readonly identifier: string }noA legacy root name, or an exported concrete HttpApi value with a literal identifier.
groupstringnoHttpApiGroup identifier.
titlestringyesOpenAPI group title.
descriptionstringyesOpenAPI group description.
displayNamestringyesOpenAPI x-displayName.
defaultsobject, table belowyesValues merged into the group's operations at compile time. Never serialized to the IR.

defaults:

FieldTypeOptionalFills
defaults.middlewareReadonlyArray<ServiceLike>yesHttp.Contract.middleware
defaults.metadata{ readonly annotator?: HttpOperationAnnotator }yesHttp.Contract.metadata.annotator
defaults.problems{ readonly registry?: ProblemRegistry }yesHttp.Problems.registry
defaults.accessPartial<Pick<HttpAccessOptions, "annotator" | "exposure" | "acceptedCredentials" | "principalKinds" | "concealment">>yesthe same-named Http.Access fields

Source: packages/runtime/src/Annotation.ts (HttpGroupOptions); IR decoding in packages/compiler/src/extensions/http-group.ts.

A string root is valid for local declarations. An external (declaration-only) group needs a concrete root symbol instead (see .declare()). The root identifier must match the Http.Contract.root string of the group's operations (spec 0010).

Class form and builder form

// class decorator: every static HTTP operation in the class belongs to the group
@Http.Group({ root: ExternalDirectoryApi, group: "directory", defaults: { /* ... */ } })
export class DirectoryMethods {
  @Query({ /* ... */ }) @Http.Get("/api/schools") static listSchools(/* ... */) { /* ... */ }
}

// builder: an exported value; each operation joins with .in(group)
export const DirectoryGroup = Http.group({ root: ExternalDirectoryApi, group: "directory" });
export const listSchools = Operation.query({ /* ... */ }).in(DirectoryGroup).http.get("/api/schools") /* ... */;
ItemDecorator formBuilder form
Declare the group@Http.Group(options) on an exported classHttp.group(options) exported value
Associate an operationstatic methods of the decorated class.in(group) on the operation builder
Recorded annotationHttp.Group [options] on the classHttp.Group [options] on the group value

Http.group is the same function as the builder's group. Http.group(options) returns { _tag: "HttpGroup", annotations }.

.in(group) (Http.In)

.in(group: HttpGroupReference) records Http.In [group]. HttpGroupReference is a HttpGroupValue or a class decorated with @Http.Group. There is no @Http.In decorator: in the class form the enclosing class is the association.

  • .in does not execute the group and writes no IR edge. The collector resolves the reference statically to one exported group declaration in the same project.
  • Http.In is stripped before interpretation, even for an invalid association.
  • An association supplies root and group to the operation's Http.Contract. Http.Contract stays an explicit annotation on every HTTP operation. An associated HTTP operation without one is EFFX2405.

Source: packages/runtime/src/builder.ts (in, group, HttpGroupReference), packages/compiler/src/group-defaults.ts, docs/specs/0013-group-defaults.md.

How group defaults merge

Defaults are source syntax. expandGroupDefaults rewrites ordinary annotations before the existing interpreters run, and calls no application function.

TargetRule
per fieldFields merge individually, never by replacing the metadata, problems or access object. A field present on the operation wins, including an empty array.
Http.Contract.root, .groupFilled from the group when absent. A different explicit value is EFFX2405.
Http.Contract.successDefaults to the operation's declared success.
Http.Contract.query: trueOn a GET Query only, means the operation's declared input schema. Otherwise EFFX2405.
Http.Contract.params, query, headers, payloadDerived from the operation's input when omitted; see Request channels derived from input. An explicit channel always wins.
Http.Contract.middlewareFilled from defaults.middleware when absent.
Http.Contract.metadata.annotatorFilled from defaults.metadata.annotator when absent. Other explicit metadata fields are kept.
Http.Contract.metadata.operationIdWhen absent and the operation name is exactly <group>.<key> with an identifier-safe key, the name is the ID. An explicit ID wins.
Http.Problems.registry and other fieldsdefaults.problems fills an existing Http.Problems. It never creates one: codes stays operation-specific.
Http.Access fieldsdefaults.access fills an existing Http.Access. It never invents capabilities, requirements, scope, decision time, or authorizes a missing operation.

Shorter source must not change meaning: a dense and a verbose declaration produce identical canonical IR, semantic hash and generated files (spec 0013 §Diagnostics and safety).

Source: packages/compiler/src/group-defaults.ts, docs/specs/0013-group-defaults.md.

Request channels derived from input

The operation's input implies the Http.Contract channel it fills (spec 0024 §2). The pre-pass writes that channel into the contract arguments, with or without a group, so IR, paramsKeys/headersKeys, hash and generated files equal the spelled-out contract. Http.headers(schema) marks a Schema as a headers schema; it is the identity function at runtime and is recognised by the brand on its static type, never by name or key pattern.

StepThe input …Becomes
0already is the Schema of an explicit channelnothing derived (input: X with headers: X stays valid)
1is wrapped by Http.headers(schema)headers; EFFX2410 when headers names another Schema
2has no path parametersGET/DELETE query; POST/PUT/PATCH payload
3has exactly the route's path parameters as keysparams
4has keys, none of them a path parameteras step 2; the explicit params stay
5mixes path parameters and other keysEFFX2410: write params and the body channel
6has unknown keys (union, brand, Void) and the route has path parametersPOST/PUT/PATCH payload; GET/DELETE EFFX2411

A derived channel never replaces an explicit one, and steps 5 and 6 are diagnostics only while nothing resolves them. A GET or DELETE input without keys derives no query; a Query over POST keeps its explicit payload (ADR 0010). Source: packages/compiler/src/request-channels.ts.

CodeSeverityCondition
EFFX2404errorassociation is unresolved, not exported, not a group, repeated, multiple (class group plus .in), or the declaration has no operation.
EFFX2405errorexplicit root or group conflicts with the group; incompatible query: true; associated HTTP operation without Http.Contract.
EFFX2406errortwo distinct raw group IDs in one root normalize to the same generated export name (spec 0013 addendum; http-group.ts).
EFFX2410errorinput mixes path parameters and other keys, or is a header schema next to a different headers.
EFFX2411errorGET/DELETE with path parameters and an input whose keys are not static, while neither params nor query is written.
EFFX2402errorduplicate @Http.Group, unsafe root or group identifier, group on something other than an exported class or builder, conflicting definitions.

Example: dense builder declarations

packages/frontend-ts/test/fixtures/stable-v4/src/profile.effx.ts
import { Capability, Concealment, Http, Operation } from "@effx/runtime";
import {
  ConditionalReadHeaders,
  IdempotencyIfMatchHeaders,
  PersonSecurity,
  ProfileCurrentPerson,
  ProfileMergePatch,
  ProfileProblemResponses,
  ProfileReadInput,
  ProfileReadResponseHeaders,
  ProfileWriteResponseHeaders,
  UserProfileResponse,
  fixtureOperationAnnotations,
  profileAccessAnnotations,
} from "./profile-support.js";
import { ExternalNativeApi } from "./profile-root.js";

export const ProfileGroup = Http.group({
  root: ExternalNativeApi,
  group: "profile",
  title: "Profile",
  description: "Authenticated self-service profile API.",
  displayName: "Profile",
  defaults: {
    middleware: [PersonSecurity],
    metadata: { annotator: fixtureOperationAnnotations },
    problems: { registry: ProfileProblemResponses },
    access: {
      annotator: profileAccessAnnotations,
      exposure: "External",
      acceptedCredentials: ["BetterAuthCookie", "OAuthUserBearer"],
      principalKinds: ["Person"],
      concealment: Concealment.reveal,
    },
  },
});

export const ProfileReadProblemCodes = [
  "request.malformed",
  "header.malformed",
  "precondition.invalid",
  "precondition.failed",
  "authority.denied",
  "origin.denied",
  "internal.error",
  "profile.not-found",
  "profile.unavailable",
] as const;

export const ProfileUpdateProblemCodes = [
  "request.malformed",
  "header.malformed",
  "authority.denied",
  "origin.denied",
  "idempotency-key.invalid",
  "idempotency.in-flight",
  "idempotency.digest-conflict",
  "idempotency.response-expired",
  "request.too-large",
  "media-type.unsupported",
  "validation.failed",
  "validation.no-change",
  "validation.field-not-deletable",
  "precondition.invalid",
  "precondition.failed",
  "precondition.required",
  "transaction.conflict",
  "internal.error",
  "profile.not-found",
  "profile.unavailable",
  "idempotency.unavailable",
] as const;

export const readOwnProfile = Operation.query({
  name: "Profile.ReadOwnProfile",
  input: ProfileReadInput,
  success: UserProfileResponse,
})
  .in(ProfileGroup)
  .http.get("/api/profile")
  .http.contract({
    headers: ConditionalReadHeaders,
    status: 200,
    responseHeaders: ProfileReadResponseHeaders,
    conditional: true,
    metadata: {
      operationId: "profile.readOwnProfile",
      summary: "Read own profile",
      description:
        "Returns the authenticated person's current profile, or 304 for a matching ETag.",
      tags: ["Profile"],
    },
  })
  .http.problems({
    codes: ProfileReadProblemCodes,
    identifier: "ProfileReadOwnProfileProblem",
  })
  .http.access({
    capabilities: Capability.one("profile.read-self"),
    requirements: [{ id: "profile.owner" }],
    canonicalScopeResolver: ProfileCurrentPerson,
    decisionTime: "SnapshotRead",
  })
  .declare();

export const updateOwnProfile = Operation.command({
  name: "Profile.UpdateOwnProfile",
  input: ProfileMergePatch,
  success: UserProfileResponse,
})
  .in(ProfileGroup)
  .http.patch("/api/profile")
  .http.contract({
    headers: IdempotencyIfMatchHeaders,
    mediaType: "application/merge-patch+json",
    status: 200,
    responseHeaders: ProfileWriteResponseHeaders,
    metadata: {
      operationId: "profile.updateOwnProfile",
      summary: "Update own profile",
      description: "Applies a merge patch with an idempotency key and If-Match precondition.",
      tags: ["Profile"],
    },
  })
  .http.problems({
    codes: ProfileUpdateProblemCodes,
    identifier: "ProfileUpdateOwnProfileProblem",
  })
  .http.access({
    capabilities: Capability.one("profile.update-self"),
    requirements: [{ id: "profile.owner" }],
    canonicalScopeResolver: ProfileCurrentPerson,
    decisionTime: "Transaction",
  })
  .declare();

Example: class decorator form

packages/frontend-ts/test/fixtures/stable-v4/src/directory-class.effx.ts
import { Effect } from "effect";
import { Http, Query } from "@effx/runtime";
import { PersonSecurity } from "./profile-support.js";
import { ExternalDirectoryApi } from "./directory-root.js";
import {
  DirectoryProblemResponses,
  DirectoryRequestMarker,
  ListSchoolsCodes,
  PrivateReadResponseHeaders,
  SchoolDirectoryResponse,
  SchoolsDirectoryQuery,
  SchoolsDirectoryResolver,
  directoryAccessAnnotations,
  directoryOperationAnnotations,
} from "./directory-support.js";

@Http.Group({
  root: ExternalDirectoryApi,
  group: "directory",
  title: "Directories",
  description: "Scoped people and school directories.",
  displayName: "Directories",
  defaults: {
    middleware: [PersonSecurity, DirectoryRequestMarker],
    metadata: { annotator: directoryOperationAnnotations },
    problems: { registry: DirectoryProblemResponses },
    access: {
      annotator: directoryAccessAnnotations,
      exposure: "External",
      acceptedCredentials: ["BetterAuthCookie", "OAuthUserBearer"],
      principalKinds: ["Person"],
      concealment: { _tag: "Reveal" },
    },
  },
})
export class DirectoryMethods {
  @Query({
    name: "directory.listSchools",
    input: SchoolsDirectoryQuery,
    success: SchoolDirectoryResponse,
  })
  @Http.Get("/api/schools")
  @Http.Contract({
    query: true,
    status: 200,
    responseHeaders: PrivateReadResponseHeaders,
    metadata: {
      summary: "List schools",
      description: "Returns the native school directory in authority scope.",
    },
  })
  @Http.Problems({ codes: ListSchoolsCodes, identifier: "DirectoryListSchoolsProblem" })
  @Http.Access({
    capabilities: { _tag: "One", capability: "schools.read-directory" },
    requirements: [],
    canonicalScopeResolver: SchoolsDirectoryResolver,
    decisionTime: "SnapshotRead",
  })
  static listSchools(_input: typeof SchoolsDirectoryQuery.Type) {
    return Effect.succeed({ schools: [] });
  }
}

.declare(): external operations

.handler(fn) ends a chain with a local operation. .declare() ends it with a declaration-only operation: no callable handler, no fake stub, no backend import into the contract package.

TerminalReturnsIR bindingHandler
.handler(fn)OperationValue<Handler>: { _tag, annotations, handler }localauthored, typed Effect
.declare()ExternalOperationValue: { _tag: "Operation", annotations }"external"none; the application binds it

Source: packages/runtime/src/builder.ts (OperationValue, ExternalOperationValue, handler, declare), docs/specs/0010-target-profiles-and-emit-modes.md §"Declaration-only operations and group metadata".

There is no decorator equivalent. A decorated static method needs a real implementation, and a stub would misstate executable behavior (docs/specs/0009-profile-proving-slice.md).

Rules for external operations:

RuleDiagnostic
An external operation must not carry a local handler or signature; a local one needs a typed handlerEFFX1106
External operations cannot have RPC, CLI or Foldkit.Command projectionsEFFX1107
An external HTTP operation needs Http.Contract and metadata.operationIdEFFX2403
operationId must be <group>.<identifier-safe key>; duplicate (root, group, key) is an errorEFFX2403
One (root, group) cannot mix local and external bindingsEFFX2403
The group needs @Http.Group / Http.group with a concrete HttpApi root symbolEFFX2402

@Errors and @Requirements stay declarations on an external operation (see Operations). The generated raw callbacks restrict typed failures to endpoint problems but infer the requirement channel from the backend binding. A declared @Requirements service is never an upper bound (spec 0010).

External bindings and emit modes

An external group generates a contract and a handler factory that the backend binds. effx build --emit=contract|handlers|all selects the projection; the default is all. Emit mode is a projection choice. It is not an annotation and does not enter the semantic hash.

ModeOutput for an external-only profile groupNotes
contractprofile-contract.tsEndpoint constants and the exported ProfileApi group. Imports no backend handler and no root.
handlersprofile-handlers.tsImports the concrete root symbol. Exports ProfileApiHandlers({ raw, guards }) using handleRaw.
allboth files, plus projections for local operationsNo external operation gains an invented handler call.

Source: docs/specs/0010-target-profiles-and-emit-modes.md §"Emit modes and raw bindings"; --emit flag in scripts/effx.ts.

Evidence for external bindings is compiler and fixture level (stable Effect 4 Profile twin). Mono-web's own backend typecheck and browser/PostgreSQL journeys are separate gates (STATE.md, specs 0009-0012).

On this page