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
| Decorator | Builder step | Signature | Records |
|---|---|---|---|
@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
| Field | Type | Optional | Meaning |
|---|---|---|---|
root | string or { readonly identifier: string } | no | A legacy root name, or an exported concrete HttpApi value with a literal identifier. |
group | string | no | HttpApiGroup identifier. |
title | string | yes | OpenAPI group title. |
description | string | yes | OpenAPI group description. |
displayName | string | yes | OpenAPI x-displayName. |
defaults | object, table below | yes | Values merged into the group's operations at compile time. Never serialized to the IR. |
defaults:
| Field | Type | Optional | Fills |
|---|---|---|---|
defaults.middleware | ReadonlyArray<ServiceLike> | yes | Http.Contract.middleware |
defaults.metadata | { readonly annotator?: HttpOperationAnnotator } | yes | Http.Contract.metadata.annotator |
defaults.problems | { readonly registry?: ProblemRegistry } | yes | Http.Problems.registry |
defaults.access | Partial<Pick<HttpAccessOptions, "annotator" | "exposure" | "acceptedCredentials" | "principalKinds" | "concealment">> | yes | the 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") /* ... */;| Item | Decorator form | Builder form |
|---|---|---|
| Declare the group | @Http.Group(options) on an exported class | Http.group(options) exported value |
| Associate an operation | static methods of the decorated class | .in(group) on the operation builder |
| Recorded annotation | Http.Group [options] on the class | Http.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.
.indoes 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.Inis stripped before interpretation, even for an invalid association.- An association supplies
rootandgroupto the operation'sHttp.Contract.Http.Contractstays an explicit annotation on every HTTP operation. An associated HTTP operation without one isEFFX2405.
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.
| Target | Rule |
|---|---|
| per field | Fields 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, .group | Filled from the group when absent. A different explicit value is EFFX2405. |
Http.Contract.success | Defaults to the operation's declared success. |
Http.Contract.query: true | On a GET Query only, means the operation's declared input schema. Otherwise EFFX2405. |
Http.Contract.params, query, headers, payload | Derived from the operation's input when omitted; see Request channels derived from input. An explicit channel always wins. |
Http.Contract.middleware | Filled from defaults.middleware when absent. |
Http.Contract.metadata.annotator | Filled from defaults.metadata.annotator when absent. Other explicit metadata fields are kept. |
Http.Contract.metadata.operationId | When 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 fields | defaults.problems fills an existing Http.Problems. It never creates one: codes stays operation-specific. |
Http.Access fields | defaults.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.
| Step | The input … | Becomes |
|---|---|---|
| 0 | already is the Schema of an explicit channel | nothing derived (input: X with headers: X stays valid) |
| 1 | is wrapped by Http.headers(schema) | headers; EFFX2410 when headers names another Schema |
| 2 | has no path parameters | GET/DELETE query; POST/PUT/PATCH payload |
| 3 | has exactly the route's path parameters as keys | params |
| 4 | has keys, none of them a path parameter | as step 2; the explicit params stay |
| 5 | mixes path parameters and other keys | EFFX2410: write params and the body channel |
| 6 | has unknown keys (union, brand, Void) and the route has path parameters | POST/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.
| Code | Severity | Condition |
|---|---|---|
EFFX2404 | error | association is unresolved, not exported, not a group, repeated, multiple (class group plus .in), or the declaration has no operation. |
EFFX2405 | error | explicit root or group conflicts with the group; incompatible query: true; associated HTTP operation without Http.Contract. |
EFFX2406 | error | two distinct raw group IDs in one root normalize to the same generated export name (spec 0013 addendum; http-group.ts). |
EFFX2410 | error | input mixes path parameters and other keys, or is a header schema next to a different headers. |
EFFX2411 | error | GET/DELETE with path parameters and an input whose keys are not static, while neither params nor query is written. |
EFFX2402 | error | duplicate @Http.Group, unsafe root or group identifier, group on something other than an exported class or builder, conflicting definitions. |
Example: dense builder declarations
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
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.
| Terminal | Returns | IR binding | Handler |
|---|---|---|---|
.handler(fn) | OperationValue<Handler>: { _tag, annotations, handler } | local | authored, 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:
| Rule | Diagnostic |
|---|---|
| An external operation must not carry a local handler or signature; a local one needs a typed handler | EFFX1106 |
External operations cannot have RPC, CLI or Foldkit.Command projections | EFFX1107 |
An external HTTP operation needs Http.Contract and metadata.operationId | EFFX2403 |
operationId must be <group>.<identifier-safe key>; duplicate (root, group, key) is an error | EFFX2403 |
One (root, group) cannot mix local and external bindings | EFFX2403 |
The group needs @Http.Group / Http.group with a concrete HttpApi root symbol | EFFX2402 |
@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.
| Mode | Output for an external-only profile group | Notes |
|---|---|---|
contract | profile-contract.ts | Endpoint constants and the exported ProfileApi group. Imports no backend handler and no root. |
handlers | profile-handlers.ts | Imports the concrete root symbol. Exports ProfileApiHandlers({ raw, guards }) using handleRaw. |
all | both files, plus projections for local operations | No 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).