effx
Authoring

Problems and access

Http.Problems and ProblemRegistry, Http.Access and its field tables, Capability and Concealment constructors, strict access.

Both annotations are source-only declarations. They contribute JSON contracts to the IR. effx never runs a registry, an annotator, a scope resolver or an access value during compilation.

Http.Problems

Http.Problems names the typed problem responses of one HTTP operation. The generated endpoint imports your exported registry function and calls it. effx keeps no code or status table.

// decorator
@Http.Problems({ registry: UserProblemResponses, codes: ["user.not-found"], map: { UserNotFound: "user.not-found" } })

// builder
.http.problems({ registry: UserProblemResponses, codes: ["user.not-found"], map: { UserNotFound: "user.not-found" } })

HttpProblemsOptions<Code extends string = string>

FieldTypeOptionalMeaning
registryProblemRegistry<Code>yesExported derivation function. Required in the IR: omit it only when the group supplies defaults.problems.registry.
codesReadonlyArray<Code>noProblem codes. The IR requires at least one non-empty, unique code.
identifierstringyesOverrides the generated schema name only, not the endpoint key or operation ID. Must be a safe identifier.
mapReadonly<Record<string, string>>yesDomain error tag to problem code. Keys are error schema export names (_tag for Schema.TaggedError). Values must occur in codes.

ProblemRegistry<Code extends string = string>:

type ProblemRegistry<Code extends string = string> = (
  identifier: string,
  codes: readonly [Code, ...Code[]],
) => ReadonlyArray<Schema.Top>;

Source: packages/runtime/src/Annotation.ts (HttpProblemsOptions, ProblemRegistry); packages/compiler/src/extensions/problem-contract.ts (ProblemsArgs, ProblemContractData).

The generated endpoint calls Registry("<endpointKey>Problem", [...codes]), or uses identifier as the first argument when given. The schemas it returns replace the default direct domain-error list for that operation (spec 0005 §Problem responses; spec 0010).

Problem naming policy

import { defineConfig } from "@effx/cli/config";

export default defineConfig({
  naming: { problemIdentifier: "{Group}{Key}Problem" },
});

The pattern is data, never a callback. {Group} is the PascalCase group name (social-events becomes SocialEvents; -, _, and . mark boundaries). {Key} uppercases only the endpoint key's first letter. {group} and {key} use the raw values. Only these four placeholders are allowed; a pattern must include {Key} or {key} and expand to a safe identifier (EFFX2412 otherwise). The CLI flag --naming-problem-identifier wins over effx.config.ts, which wins over the tsconfig effx.naming block. With no policy the default remains <endpointKey>Problem; an explicit operation identifier always wins.

Two derived names with different code lists cannot share a schema name (EFFX2413); equal code lists may share one. Explicit overrides remain available for deliberately shared unions such as SchoolAdministrationProblem.

Naming changes semantics, not just emitted text: the prepass writes the resolved identifier into IR, exactly like the explicit spelling. The manifest records naming. Contract and handlers passes must use the same pattern to keep their semantic hashes equal (spec 0024 §5).

CodeCondition
EFFX2402duplicate Http.Problems; no HTTP exposure; duplicate codes; unsafe identifier
EFFX2205an operation error has neither a map entry nor a sourced HTTP status; or map names a tag that is not an operation error
EFFX2206map points to a code absent from codes

The generated handler is an ordinary Effect handler. Its domain-error to Problem conversion and ProblemBoundaryLive are hand-written until an explicit runtime mapping contract exists (spec 0005). A typed generated API that does not accept that handler is a failure, not a reason to weaken the error union.

Http.Access

Http.Access records one operation's protection policy: credentials, principals, capabilities, requirements, scope resolver, concealment and decision time.

// decorator
@Http.Access({
  annotator: profileAccessAnnotations,
  exposure: "External",
  acceptedCredentials: ["BetterAuthCookie", "OAuthUserBearer"],
  principalKinds: ["Person"],
  capabilities: Capability.one("profile.read-self"),
  requirements: [{ id: "profile.owner" }],
  canonicalScopeResolver: ProfileCurrentPerson,
  concealment: Concealment.reveal,
  // decisionTime omitted: a Query decides in a snapshot, a Command in its transaction.
})

// builder
.http.access({ /* same options */ })

It is allowed once per operation (EFFX2500 otherwise).

HttpAccessOptions

HttpAccessOptions is HttpAccessAnnotationSpec with exposure, acceptedCredentials, principalKinds, concealment and decisionTime made optional, plus an optional annotator.

FieldTypeOptionalMeaning
annotator(spec: HttpAccessAnnotationSpec) => Context.Context<never>yesExported function, lowered as a SymbolRef, never called by the decorator or compiler.
exposure"External" or "Internal"yesAn Internal root is excluded from the published client and index.
acceptedCredentialsNonEmptyStringsyesNamed credential alternatives.
principalKindsNonEmptyStringsyesAllowed principal kinds.
capabilitiesAccessCapabilitiesnoCapability expression.
requirementsReadonlyArray<{ id: string; parameters?: Readonly<Record<string, AccessJson>> }>noApplication-defined typed requirements with JSON-only parameters.
canonicalScopeResolverobjectnoApplication-exported value, lowered to a SymbolRef. effx implements no scope lookup.
concealmentAccessConcealmentyesWhat a denied caller sees.
decisionTime"SnapshotRead" or "Transaction"yesWhere the application decides authority. Defaults from the kind: Query is SnapshotRead, Command is Transaction; an explicit value wins.
snapshotDecisionForCommandbooleanyesCompiler-only claim (ADR 0013). Only true has meaning; the annotator never receives it.

HttpAccessAnnotationSpec is the value passed unchanged to your annotator in generated code. All its fields are required: exposure, acceptedCredentials, principalKinds, capabilities, requirements, canonicalScopeResolver, concealment, decisionTime, with the types above. An omitted decisionTime is filled before interpretation (spec 0024 §4), so the annotator and the IR always see the resolved value. The group's defaults.access has no decisionTime: it is per operation by nature.

Source: packages/runtime/src/Annotation.ts (HttpAccessOptions, HttpAccessAnnotationSpec, AccessJson, NonEmptyStrings); IR shape in packages/compiler/src/extensions/access-contract.ts (AccessOptions).

Every field is required in the IR. The five optional fields that a group can default (annotator, exposure, acceptedCredentials, principalKinds, concealment) must come from defaults.access when the operation omits them, or the arguments fail to decode (EFFX1102). See HTTP.

Value constraints the IR enforces

The TypeScript types are looser than the IR in two places.

FieldTypeScript typeIR accepts
principalKindsNonEmptyStringsnon-empty unique array of Anonymous, Person, ServicePrincipal, CapabilityHolder
acceptedCredentialsNonEmptyStringsnon-empty unique array of non-empty strings. Spec 0006 names None, BetterAuthCookie, OAuthUserBearer, OAuthServiceBearer, ObjectCapability; later names stay data, validated by your annotator.
requirements[].parametersReadonly<Record<string, AccessJson>>a JSON object

NonEmptyStrings is readonly [string, ...string[]]. AccessJson is null, string, number, boolean, an array of AccessJson, or a record of AccessJson.

AccessCapabilities and Capability constructors

Tagged valueConstructorShape
{ _tag: "None" }Capability.none (a value)no capability required
{ _tag: "One", capability: string }Capability.one(cap)one capability
{ _tag: "Any", capabilities: NonEmptyStrings }Capability.any(first, ...rest)any of several
{ _tag: "All", capabilities: NonEmptyStrings }Capability.all(first, ...rest)all of several

AccessConcealment and Concealment constructors

Tagged valueConstructorMeaning
{ _tag: "Reveal" }Concealment.reveal (a value)the denial is visible
{ _tag: "NotFound", stages: NonEmptyStrings }Concealment.notFound(firstStage, ...rest)denial is concealed as not found; stages are application-defined strings

The constructors return exactly the tagged values. Writing the tagged literal instead is still accepted and produces identical canonical IR, semantic hash and generated bytes. The source frontend recognizes the constructors only after resolving their root symbol to @effx/runtime (including renamed imports); a non-static argument is a source diagnostic. Source: packages/runtime/src/authority.ts; docs/specs/0013-group-defaults.md §"source constructors for tagged access values".

Capability here is the same export as Capability.make on the Persistence and authority page.

Analyses

CodeSeverityCondition
EFFX2500errorduplicate Http.Access, or malformed access contract data
EFFX2501errora Command declares decisionTime: "SnapshotRead" without an accepted snapshotDecisionForCommand claim
EFFX2502warninga Query declares decisionTime: "Transaction"
EFFX2503erroracceptedCredentials has any entry other than None but Http.Contract.middleware has no security marker
EFFX2504warning; error with strictAccess: truean operation with HTTP exposure has no Http.Access
EFFX2506errorsnapshotDecisionForCommand: true outside a Command with SnapshotRead, empty requirements, sole ObjectCapability and sole CapabilityHolder (EFFX2501 also remains for an unsafe Command)
EFFX2414errordecisionTime omitted on a declaration with no single Query or Command to default it from

Source: packages/compiler/src/extensions/access-contract.ts (analyzeAccess).

Strict access

ProjectConfig.strictAccess?: boolean defaults to false. The CLI exposes it as --strict-access on effx check and effx build. It changes only EFFX2504 from warning to error; generation is skipped under strict mode. Warnings alone do not block generation. The value travels through an immutable AnalysisContext; no module-level mode or environment variable changes severity.

effx check --strict-access
effx build --strict-access

Source: packages/compiler/src/Collected.ts (strictAccess), packages/compiler/src/Extension.ts, scripts/effx.ts (Flag.Boolean("strict-access")), docs/specs/0006-access-contract-extension.md. Spec 0006 states that the CLI's other commands do not claim an access gate.

Generated shape: guards

The generator calls endpoint.annotateMerge(Annotator({ ... })) after the endpoint's middleware and other annotations. For a protected operation it also requires an application guard:

  • guards.ts exports one Guards record type per HTTP group with protected operations, keyed by qualified operation ID. It is emitted only in emit mode all.
  • Each guard has the type of the handler's authored second argument: a lazy authorize: () => Effect<Principal, Problem, R>.
  • The generated handler passes a thunk () => guards[operationId](request) as that argument. The handler calls it inside its own read snapshot or committing transaction. No generated preflight grants authority.
  • A missing or non-Effect callback makes the generated code fail TypeScript checking.

Source: docs/specs/0006-access-contract-extension.md §"HTTP lowering and guard interface"; packages/compiler/src/generate/guards.ts.

Runtime enforcement is app-owned. effx never executes an Http.Access value, resolves a credential, interprets relationship facts, chooses a principal or issues a lease. A required typed binding is not proof that the handler called it. Spec 0006 states the real security gate is the application's own journey: a static annotation cannot substitute for observing a transaction denial, replay or revocation. Cedar authorization and leases are deferred (ADR 0007).

Examples

Class-decorator form with group defaults for problems and access:

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: [] });
  }
}

Builder form with Capability.one, Concealment.reveal and .declare(): see profile.effx.ts.

On this page