The observable failure boundary (ADR-0041)
docs/adrs/0041-effect-native-backend-execution-failure-and-observability.md (with ADR-0042 as its HTTP transport profile) defines how failures move through the backend: typed failures keep their causal evidence until a terminal runtime boundary makes an explicit outcome decision — and that boundary is the only thing allowed to log them.
Why it exists
Before ADR-0041, the codebase mixed styles: some ports returned Effect with typed errors, others Promise rejecting with unknown; call sites mapped rejections straight to empty 400/500s (losing the cause before any trace saw it) or logged-and-rethrew (duplicate terminal logs); HTTP statuses and retry policy leaked into reusable packages. The ADR’s goals: preserve typed failures and evidence to the boundary, keep package error algebras small and protocol-neutral, distinguish failure vs defect vs interruption, correlate every terminal failure to its trace/invocation/release, and never leak raw causes, payloads, SQL, secrets, or tenant data through logs or encoded errors.
The wire contract: packages/api-failure-contract
Every API failure crosses the wire as a bounded envelope:
// packages/api-failure-contract/src/envelope.ts
interface ApiErrorEnvelope<Code extends string> {
readonly error: {
readonly code: Code // from a per-group failure catalog
readonly message: string // length-capped safe copy
readonly requestId: string
readonly violations?: ReadonlyArray<{ path; code }> // count-capped
}
}Codes come from composable catalogs (composeApiFailureCatalogs, per-group entries under src/groups/, plus platform-codes.ts); total response size is capped at API_ERROR_RESPONSE_MAX_LENGTH (8 KiB). The portal decodes envelopes with the same package (see portal data access) — one shared contract, no guessing at error shapes.
Runtime implementation in apps/api
The pieces live in apps/api/src/failure-protocol/:
withApiFailureProtocol(api)brands anHttpApisomakeAppLayerrefuses un-protocoled apis.makeObservedApiFailureGroupRuntimeLayer('kpi', { servedContract: 'envelope', observationProfile: 'terminal' })— the per-group runtime layer every entry installs as middleware.makeApiFailureTransportError— builds the typed transport failure (group, status, catalog code, requestId).ApiFailureEvidenceStore— carries the rich evidence (the original cause, mechanism, etc.) alongside the request for terminal observation, without ever serializing it to the client.summarizeApiFailureCause— reduces an EffectCauseto bounded counts (fail/die/interrupt reason counts). This is whatserver.tslogs on unhandled causes; the framework logger is disabled so nothing else can print a raw stack.hardenApiFailurePlatformResponse— final response hardening applied intoLambdaHandler.
A concrete example — the KPI entry mapping an auth failure (from apps/api/src/entries/kpi.ts):
const failure = makeApiFailureTransportError({
group: 'kpi',
status: 401,
code: 'ApiUnauthorized',
requestId: requestContext.requestId,
sourceEvidence: evidence, // { kind: 'auth', mechanism: 'workos_jwt', source: cause, ... }
});
const evidenceStore = yield* Effect.serviceOption(ApiFailureEvidenceStore);
if (evidenceStore._tag === 'Some') evidenceStore.value.register(failure, evidence);
return yield* Effect.fail(failure);The client sees { error: { code: "ApiUnauthorized", message, requestId } }; the evidence (including the original cause) stays server-side for the terminal observer.
Pitfalls
- A discarded cause is unrecoverable. If a handler maps an error to a typed 500 without preserving the cause at the
catch:site, the framework tap can only record which route 500’d — mapped transport failures must carry their own evidence. - Log once, at the boundary. Logging an error inside a package and also rethrowing produces the duplicate terminal logs ADR-0041 exists to eliminate. Packages return typed failures; the runtime observes.
- No HTTP in packages. Status codes, retry policy, and DLQ behavior are runtime decisions — reusable packages stay protocol-neutral (ADR-0041 non-goals).
- Gates enforce this.
check:api-failure-serving-gateandcheck:api-failure-no-new-debtrun insidebun run verify(step 6 of adding an endpoint); a group that doesn’t serve the envelope, or new untyped failure debt, fails CI.
Related: request-level tracing and the http.request root span are covered in the architecture overview; ADR-0016 (audit vs telemetry) and ADR-0027 (authorization evidence) govern the durable audit events this protocol deliberately does not replace.