This note expands the ERR, RENDER, and HTTP tracks from
todo.md. It is the current reference for expected failures,
typed service errors, and HTTP error boundaries.
For the migration architecture and queue, see
error-boundaries-plan.md.
Schema.TaggedErrorClass.Effect.die(...) is reserved for defects: bugs, impossible states,
violated invariants, and final unknown-boundary fallbacks.Error: SomeName strings.export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
sessionID: SessionID,
message: Schema.String,
}) {}
export type Error = Storage.Error | SessionBusyError
export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}
Rules:
Schema.TaggedErrorClass for expected domain failures.Error union from each service module.yield* new DomainError(...) for direct early failures in
Effect.gen / Effect.fn.Schema.Defect for unknown cause fields when preserving the cause is
useful for logs or callers.Effect.try(...), Effect.tryPromise(...), Effect.mapError,
Effect.catchTag, and Effect.catchTags to translate external
failures into domain errors.throw, Effect.die(...), or catchDefect for expected
user, IO, validation, missing-resource, auth, provider, worktree, or
busy-state failures.Service modules stay transport-agnostic. They should not import HTTP
status codes, HttpApiError, HttpServerResponse, or route-specific
error schemas.
HTTP handlers translate service errors into public endpoint errors:
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session
.get(ctx.params.sessionID)
.pipe(Effect.catchTag("StorageNotFoundError", () => notFound("Session not found")))
})
Endpoint definitions declare which public errors can be emitted. Public
HTTP error schemas carry their response status with httpApiStatus or the
equivalent HttpApi schema annotation.
Effect's own HttpApi examples follow this pattern:
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class Authorization extends HttpApiMiddleware.Service<
Authorization,
{
provides: CurrentUser
}
>()("app/Authorization", {
security: { bearer: HttpApiSecurity.bearer },
error: Unauthorized,
}) {}
Endpoint-level errors use the same idea:
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
{
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
HttpApiEndpoint.get("get", "/config", {
success: Config.Info,
error: ConfigApiError,
})
The service error and HTTP error may be the same class only when the wire shape is intentionally public. Use separate HTTP error schemas when the service error contains internals, low-level causes, retry hints, or data that should not be exposed to API clients.
Do not map every domain error into one universal HTTP error class. Prefer a
small public error vocabulary by route group: shared shapes like
ApiNotFoundError, route-specific shapes like ConfigApiError, and built-in
empty HttpApiError.* only when an empty/no-content body is the intended SDK
contract.
unknown -> status mapper.{ name, data } bodies until a deliberate
breaking API change.HttpApiError.* only when its generated body and SDK
surface are intentionally the public contract.Schema.ErrorClass for public HTTP error bodies whose wire shape is
not the same as the internal domain error shape.Schema.TaggedErrorClass for service/domain errors and middleware
errors that are naturally tagged by _tag.{ name, data } body, model that shape explicitly in
the public API error schema instead of relying on NamedError.toObject() in
generic middleware.HTTP serialization and user rendering are separate boundaries. The server should send structured public errors; CLI and TUI code should format those structures through one shared formatter.
For SDK calls using { throwOnError: true }, the generated client may wrap the
decoded response body in an Error. The original body should remain available
under error.cause.body; FormatError is the right place to unwrap and render
that body. TUI aggregation helpers should call FormatError first, then fall
back to generic Error.message / string rendering.
When several parallel startup requests fail from the same underlying issue, group identical rendered messages and list the affected request names once. For example:
Configuration is invalid at /path/to/kirincode.json
↳ Expected object, got "not-object" provider.bad.options
Affected startup requests: config.providers, provider.list, app.agents, config.get
HTTP middleware should be cross-cutting: auth, context, schema decode formatting, routing, and final unknown-defect fallback.
The current compatibility middleware still knows about some legacy domain errors. As route groups declare expected errors and handlers map them, that middleware should shrink. It should not gain new name checks.
Unknown 500 responses should log full details server-side with
Cause.pretty(cause) and return a safe public body.
The config startup regression in #27056 is the failure mode this rule is meant
to avoid: a user-authored invalid kirincode.json crossed the HttpApi boundary
as a defect, so middleware replaced a useful ConfigInvalidError with a safe
generic UnknownError. The compatibility fix is to preserve config parse and
validation errors as client-visible 400s. The target architecture is better:
config loading should fail on the typed error channel, config HTTP handlers
should map those errors to declared ConfigApiError responses, and the generic
middleware should never see them.
Prefer small vertical slices:
Schema.TaggedErrorClass errors.Good early domains are storage not-found, worktree errors, and provider auth validation errors because they currently drive HTTP behavior.
Config parse and validation errors are also a good early slice because they are startup-blocking and must be rendered clearly in both CLI and TUI flows.