Files
alkhttp/docs/architecture/decisions/023-operation-error-schemas.md
T
glm-5.3-flash 320ea87b08 docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
Phase 1 (SDD) — architecture documentation:

Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint
gateway, channels-over-WS, Sub/Pub operation types):
- overview.md, http-server.md, http-adapters.md, http-mcp.md
- README.md index (rewritten for alkhttp)

New ADRs:
- 067: WebSocket carries the channels protocol (8-byte chunk demux,
  channel 0 = alk/call, upgrade path /alk/channels)
- 068: gateway /publish endpoint for Pub operations (NDJSON body)
- 069: WebTransport out of scope in alkhttp (alknet concern)
- 070: from_wss consumer adapter (wss feature, tokio-tungstenite)

Ported ADRs (25, same numbers, port notes + amendments where the
extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027,
034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066.

websocket.md rewritten for the channels session; open-questions.md
seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing,
OQ-03 from_wss reconnect, OQ-04 browser client ownership).

Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
2026-08-27 14:19:24 +00:00

24 KiB
Raw Blame History

ADR-023: Operation Error Schemas

Ported from alknet ADR-023 (Operation Error Schemas); re-targeted to alkhttp.

Status

Accepted (amended by alknet ADR-049 — protocol-level code list extended to six; alkcall ADR-021)

Context

The OperationSpec in the call crate (now alkcall) has input_schema and output_schema but no error_schemas. The call.error payload (the alkcall crate's call-protocol.md) carries a code and message, where code is one of six infrastructure codes: NOT_FOUND, FORBIDDEN, INVALID_INPUT, INVALID_OPERATION_TYPE, INTERNAL, TIMEOUT.

These six codes cover protocol-level failures — the call protocol itself can always fail to find an operation, deny access, reject bad input, reject the wrong dispatch method for the operation type, time out, or hit an internal error. They are emitted by the dispatch machinery (the registry, the adapter), not by operation handlers. INVALID_OPERATION_TYPE was added by alknet ADR-049 (streaming handler for subscriptions — now alkcall ADR-021; invoke() called on a Sub, or invoke_streaming() on a Query/Mutation).

But operations also have domain-level failures that are not covered:

  • /fs/readFile can fail because the file doesn't exist, the path is invalid, or the caller lacks OS-level read permission. These are operation-specific failures distinct from the protocol-level INVALID_INPUT (schema mismatch) or FORBIDDEN (scope mismatch).
  • /vastai/createMachine can fail because the account has insufficient credits, the machine type is unavailable in the requested region, or the upstream API rate-limited the request.
  • /agent/chat can fail because the LLM provider returned an error, the context window overflowed, or the model refused the request.

Today, these failures collapse into INTERNAL with a message string. A consumer calling /fs/readFile has no way to know from the schema that it might return FILE_NOT_FOUND vs PERMISSION_DENIED vs INVALID_PATH. The caller has to parse message strings — the exact anti-pattern that typed RPC is meant to avoid. This is a type safety gap: inputs and outputs are typed, but errors are untyped strings.

Why this matters for adapters

OpenAPI specs naturally include error information — response status codes with schemas (e.g., 404: { schema: NotFoundError }, 422: { schema: ValidationError }). MCP tool definitions carry error descriptions. The from_openapi adapter (ADR-017) imports operations and mirrors "the remote operation's name, namespace, type, schemas, and access control" — but with no error schema field, error responses from the OpenAPI source are dropped on import. to_openapi has nowhere to project error information to. The same gap applies to from_mcp/to_mcp.

An OpenAPI operation that declares:

responses:
  '200': { schema: MachineList }
  '401': { schema: AuthError }
  '429': { schema: RateLimitError }

cannot be faithfully represented in the call protocol's OperationSpec today. The adapter would import the 200 output schema and drop the error schemas — a lossy import that silently discards the operation's failure contract.

Prior art

The TypeScript reference (/workspace/@alkdev/operations/src/types.ts L3847, L94, L112) defines ErrorDefinitionSchema and an optional errorSchemas?: ErrorDefinition[] on OperationSpec:

export const ErrorDefinitionSchema = Type.Object({
  code: Type.String({ description: "Error Code e.g., INVALID_INPUT, NOT_FOUND, UNAUTHORIZED" }),
  description: Type.String(),
  schema: Type.Unknown(),
  httpStatus: Type.Optional(Type.Number()),
});

The mapError() function (error.ts L2551) matches thrown errors against the declared error schemas by code prefix — if a handler throws an error whose message starts with a declared code, mapError rewrites it to a typed CallError with that code. This is a proven pattern: operations declare their error contract, the dispatch machinery maps runtime failures to the declared codes, and consumers get typed errors instead of string parsing.

The translator agent omitted errorSchemas from the Rust spec, likely because it's Optional in the TS schema (so dropping it doesn't break the happy path) and because error schemas are semantically different from input/output schemas (an operation returns one output but could return any of several errors). That's a reasonable judgment call for a first translation pass, but it leaves a real gap for adapters and consumers.

The general principle

This is the same principle as the Safe Exit protocol in the SDD process (docs/sdd_process.md L19, L423): make failure a typed, declared thing rather than an untyped exception that crashes into whoever's listening. An operation that declares "I can fail with FILE_NOT_FOUND" is the same shape as an agent that declares "I can fail with TASK_AMBIGUOUS" — both turn an unknown unknown into a known known that the caller can handle deliberately.

Complex systems survive not because every component is reliable, but because failure is expected and typed. Cells have apoptosis (a declared failure mode that protects the organism). Operations have error schemas (a declared failure mode that lets the caller handle it). The alternative — components that fail with untyped strings — is how you get brittle clients that string-match error messages and break when the message wording changes.

Decision

1. OperationSpec gains an optional error_schemas field

pub struct OperationSpec {
    pub name: String,
    pub namespace: String,
    pub op_type: OperationType,
    pub visibility: Visibility,
    pub input_schema: Value,
    pub output_schema: Value,
    pub access_control: AccessControl,
    pub error_schemas: Vec<ErrorDefinition>,  // NEW — empty vec = no declared errors
}

pub struct ErrorDefinition {
    /// Machine-readable error code. e.g., "FILE_NOT_FOUND", "RATE_LIMITED",
    /// "INSUFFICIENT_CREDITS". Distinct from the protocol-level codes
    /// (NOT_FOUND, FORBIDDEN, etc.) — these are operation-level domain codes.
    pub code: String,

    /// Human-readable description of when this error occurs.
    pub description: String,

    /// JSON Schema for the error detail payload. The `call.error` event's
    /// `details` field conforms to this schema when this error code is
    /// returned. `Value` (serde_json::Value) carrying a JSON Schema, same
    /// as input_schema/output_schema.
    pub schema: Value,

    /// HTTP status code for adapter projection. `from_openapi` maps OpenAPI
    /// response status codes to error definitions; `to_openapi` projects
    /// error definitions back to response status codes. Optional — not all
    /// error sources are HTTP-backed.
    pub http_status: Option<u16>,
}

error_schemas is a Vec<ErrorDefinition>, not Option<Vec<...>>. An empty vec means "this operation declares no specific domain errors" (it may still fail with protocol-level codes like INTERNAL). This avoids the None vs Some([]) ambiguity and matches the TypeScript reference's optional-array convention.

2. The call.error payload gains an optional details field

{
  "code": "FILE_NOT_FOUND",
  "message": "file not found: /etc/nonexistent",
  "retryable": false,
  "details": { "path": "/etc/nonexistent", "errno": 2 }
}
  • code — the error code. Either a protocol-level code (NOT_FOUND, FORBIDDEN, INVALID_INPUT, INVALID_OPERATION_TYPE, INTERNAL, TIMEOUT) or an operation-level domain code from error_schemas (e.g., FILE_NOT_FOUND, RATE_LIMITED).
  • message — human-readable error message. Unstructured — for logging and debugging, not for programmatic handling. Consumers should switch on code, not parse message.
  • retryable — whether the caller should retry. true for transient failures (TIMEOUT, RATE_LIMITED), false for permanent ones (NOT_FOUND, FORBIDDEN, FILE_NOT_FOUND).
  • details — optional. When the error code matches a declared ErrorDefinition, details conforms to that definition's schema. When the error is protocol-level (NOT_FOUND, FORBIDDEN, etc.), details is absent or carries protocol-specific context (e.g., the operation name for NOT_FOUND). This field is the typed error payload — it's what makes errors structured instead of string-matched.

3. Protocol-level vs operation-level error codes

The six existing codes are protocol-level — emitted by the dispatch machinery, not by handlers:

Code Emitted by Meaning
NOT_FOUND Registry Operation not registered (or Internal op called from wire)
FORBIDDEN Registry / ACL Caller lacks required scopes, or unauthenticated
INVALID_INPUT Registry Input doesn't match input_schema
INVALID_OPERATION_TYPE Registry / OperationEnv Wrong dispatch path for the operation's type (invoke() on a Sub, invoke_streaming() on a Query/Mutation, invoke_sink() on a Query/Mutation/Sub, or OperationEnv::invoke() on a Sub during composition — alkcall ADR-021; Pub ops dispatch via invoke_sink(), alkcall ADR-046)
INTERNAL Registry / Adapter Handler panic, unhandled error, connection failure
TIMEOUT Adapter Request timed out

Operation-level domain codes are emitted by handlers — the operation's own logic determines what went wrong. They are declared in error_schemas and appear in the code field of call.error. Examples: FILE_NOT_FOUND, PERMISSION_DENIED, RATE_LIMITED, INSUFFICIENT_CREDITS, CONTEXT_OVERFLOW.

The two namespaces are distinct but share the code field. Consumers should handle protocol-level codes uniformly (they mean the same thing regardless of operation) and operation-level codes per-operation (they mean what the operation's error_schemas says they mean). Unknown codes — whether a future protocol code or an undeclared operation code — should be treated as INTERNAL with retryable: false (same as the current guidance in the alkcall crate's call-protocol.md).

4. Handler error mapping

When a handler returns an error, the dispatch machinery maps it to a call.error event. The mapping:

  1. If the handler returns a structured error with a code that matches a declared ErrorDefinition.code, the call.error carries that code and the error's detail payload (validated against the definition's schema).
  2. If the handler returns a structured error with a code that doesn't match any declared ErrorDefinition, the call.error carries INTERNAL with the original code in details. This is an undeclared error — the handler returned a typed error but didn't declare it.
  3. If the handler returns an unstructured error (a string, a generic Error, a panic), the call.error carries INTERNAL with retryable: false. This is the current behavior for all handler errors.

The TypeScript mapError() function (error.ts L2551) implements case 2 and 3 by matching error messages against declared codes. The Rust implementation can use a typed error return from the handler (Result<Value, CallError> where CallError carries a code), which is cleaner than message-string matching — the handler returns a typed error, the registry checks whether the code is declared, and the call.error is constructed accordingly.

5. from_openapi and to_openapi error fidelity

from_openapi maps OpenAPI response status codes to ErrorDefinitions:

// OpenAPI: 404: { schema: NotFoundError }
// → ErrorDefinition { code: "HTTP_404", http_status: Some(404), schema: NotFoundError }

Normative rule (review #002 W20): from_openapi must not produce error codes that collide with the six protocol-level codes (NOT_FOUND, FORBIDDEN, INVALID_INPUT, INVALID_OPERATION_TYPE, INTERNAL, TIMEOUT). The adapter prefixes imported error codes with HTTP_ and the status number (e.g., HTTP_404, HTTP_429) to avoid collision. This is a requirement for the adapter, not a naming convention — the from_openapi example above was previously shown producing NOT_FOUND from a 404, which collided with the protocol-level NOT_FOUND (operation not registered). The details field disambiguates in practice (present for operation-level, absent for protocol-level), but this ADR says "consumers should switch on code, not parse message" — so the code alone must be unambiguous. Operations that hand-write their own ErrorDefinitions should use domain-specific codes (FILE_NOT_FOUND, RATE_LIMITED) rather than reusing protocol codes.

The adapter maps the OpenAPI error schema to the call protocol's JSON Schema format (same conversion as input/output schemas). The http_status field records the original status code so to_openapi can project it back.

to_openapi projects error_schemas back to OpenAPI response definitions:

responses:
  '200': { schema: <output_schema> }
  '404': { schema: <error_schemas[0].schema> }  # where http_status = 404
  '429': { schema: <error_schemas[1].schema> }  # where http_status = 429

This makes the adapter contract from ADR-017 faithful on the error axis — no silent dropping of error contracts. Both from_openapi and to_openapi are implemented in alkhttp; the HTTP_<status> mapping is part of this crate's gateway error-fidelity contract (ADR-047).

from_mcp and to_mcp follow the same pattern: MCP tool definitions carry error descriptions, and the adapters map them to/from ErrorDefinitions.

6. services/schema exposes error schemas

services/schema returns the full OperationSpec including error_schemas. A consumer querying /services/schema for /fs/readFile gets:

{
  "name": "fs/readFile",
  "namespace": "fs",
  "op_type": "query",
  "input_schema": { ... },
  "output_schema": { ... },
  "error_schemas": [
    { "code": "FILE_NOT_FOUND", "description": "The file does not exist",
      "schema": { "type": "object", "properties": { "path": { "type": "string" } } },
      "http_status": null },
    { "code": "PERMISSION_DENIED", "description": "OS-level read permission denied",
      "schema": { "type": "object", "properties": { "path": { "type": "string" }, "errno": { "type": "integer" } } },
      "http_status": null }
  ]
}

This enables client code generation: a TypeScript or Rust client generator reading the schema can produce a typed Result<Output, FsReadFileError> enum instead of a generic Result<Output, string>.

Consequences

Positive:

  • Operations declare their failure modes. Consumers get typed errors instead of string-matched messages. This is the same type-safety property that input_schema and output_schema provide, extended to the error axis.
  • from_openapi and to_openapi are faithful on the error axis. An OpenAPI operation's error contract is no longer silently dropped on import or absent on export. The adapter contract from ADR-017 is now complete.
  • Client code generation can produce typed error enums. A consumer calling /fs/readFile can match on FILE_NOT_FOUND vs PERMISSION_DENIED instead of parsing message strings.
  • The protocol-level vs operation-level distinction is explicit. Protocol codes (NOT_FOUND, FORBIDDEN, etc.) mean the same thing regardless of operation. Operation codes (FILE_NOT_FOUND, RATE_LIMITED) mean what the operation declares. No conflation.
  • The details field carries structured error context that conforms to a schema — the error payload is typed, not a bare string. This enables programmatic error handling (retry logic, user-facing error messages, logging) without string parsing.
  • The principle generalizes: making failure a typed, declared thing is the same pattern as the SDD process's Safe Exit protocol (typed agent failure) and the same pattern complex biological systems use (apoptosis as a declared cell failure mode). The more components declare their failure modes, the more robust the system.

Negative:

  • OperationSpec gains a field. Operations that don't declare errors (empty error_schemas vec) still work — the field is additive. But operations that should declare errors and don't will produce INTERNAL with retryable: false, same as today. The gap is visible but not enforced — an operation can ship without error schemas and consumers get untyped errors for it. This is a documentation/guidance issue, not a type-system issue.
  • The call.error payload gains a details field. This is a wire-format addition. Existing clients that only read code and message are unaffected (they ignore details). New clients can read details for structured error context. This is backward-compatible — details is optional and absent for protocol-level errors.
  • Handler error mapping adds a step to the dispatch path: the registry checks whether the handler's error code matches a declared ErrorDefinition. This is a HashMap lookup by code — negligible cost.
  • The http_status field on ErrorDefinition is HTTP-specific. Operations that aren't HTTP-backed (local, session, from_mcp) leave it as None. This is a pragmatic choice: from_openapi/to_openapi need it, and it's optional for everything else. A future non-HTTP adapter that needs a different error projection field would add it — but http_status covers the immediate use case.
  • The TypeScript mapError() uses message-string matching to map thrown errors to codes. The Rust implementation can do better (typed CallError return from handlers), but this means the Handler type's return is Result<Value, CallError> rather than Result<Value, Box<dyn Error>>. This is a cleaner API but a slight constraint on handler authors — they return typed errors, not generic ones. Mitigated: CallError::internal() is available for errors that don't fit a declared code.

Assumptions

  1. Operations can enumerate their meaningful failure modes at registration time. If an operation has failure modes that are only discoverable at runtime (e.g., a dynamic API that returns novel error codes), those would be INTERNAL with details carrying the upstream error. The assumption is that most operations have a knowable set of domain errors.

  2. Error codes are stable per operation. Once an operation declares FILE_NOT_FOUND, consumers depend on that code. Changing it (renaming to NOT_FOUND_FILE) is a breaking change for consumers that match on it. This is the same stability property as input_schema and output_schema — the operation's interface is its contract. Adding new error codes is additive (consumers that don't know the new code treat it as INTERNAL); removing or renaming codes is breaking.

  3. Protocol-level codes are distinct from operation-level codes. If an operation declares a code that collides with a protocol code (e.g., an operation declares NOT_FOUND as a domain error), the protocol code takes precedence in the dispatch machinery (the registry's NOT_FOUND for "operation not registered" is emitted before the handler runs). The assumption is that operations use domain-specific codes (FILE_NOT_FOUND) rather than reusing protocol codes (NOT_FOUND). This is a naming convention, not a type-system enforcement.

  4. details is optional and backward-compatible. Existing clients that ignore details continue to work. New clients read details for structured context. The wire format addition is additive.

References

  • ADR-017: Call protocol client and adapter contract (adapter fidelity — this ADR makes from_openapi/to_openapi faithful on the error axis; the decision record is alkcall ADR-022)
  • ADR-014: Secret material flow (the details field must not carry secret material — same constraint as metadata)
  • ADR-015: Privilege model (the FORBIDDEN protocol code covers ACL denial; operation-level PERMISSION_DENIED is a distinct domain error for OS-level permission issues)
  • alknet mono-repo docs/reviews/001-pre-implementation-architecture-sanity-check.md (finding C5, which this ADR resolves)
  • alkcall ADR-021: Streaming handler for subscriptions (alknet ADR-049, ported here as ADR-049; amends this ADR's protocol-level code list — INVALID_OPERATION_TYPE added as the sixth protocol-level code)
  • alkcall ADR-046: Pub operation type and HandlerKind::Sink — the producer→consumer streaming primitive; Pub ops dispatch via invoke_sink()
  • alknet mono-repo docs/sdd_process.md L19, L423 (Safe Exit protocol — the general principle of making failure typed and declared)
  • TypeScript reference: /workspace/@alkdev/operations/src/types.ts L3847 (ErrorDefinitionSchema), L94, L112 (errorSchemas on OperationSpec), error.ts L2551 (mapError)

Port notes

  • Renames: "alknet-http" → alkhttp; "alknet-core"/"alknet-call" → alkcall ("the call crate (now alkcall)").
  • OperationType::SubscriptionSub (alkcall rename; alkcall ADR-046 added OperationType::Pub, producer→consumer streaming via call.published, HandlerKind::Sink). Corrections flowing from this: the INVALID_OPERATION_TYPE table row now lists the dispatch-path mismatches per the current handler-kind model (invoke() on a Sub, invoke_streaming() on a Query/Mutation, invoke_sink() on a Query/Mutation/Sub, and OperationEnv::invoke() on a Sub during composition) and cites alkcall ADR-021 (this crate's ADR-049) and alkcall ADR-046 for Pub/Sink. from_openapi produces no Pub ops in v1 (SSE responses detect as Sub), so the adapter-fidelity discussion is unaffected by Pub.
  • Producer/consumer terminology: "A client calling /fs/readFile" → "a consumer"; "clients get typed errors" → "consumers get typed errors"; §2 code bullet "Clients should switch on code" → "Consumers should switch on code"; §3 "Clients should handle protocol-level codes" → "Consumers should handle". HTTP/MCP/OpenAPI client references (§5's "HTTP clients", the client code generation, "brittle clients") keep their names — those are inherent-directionality roles of the external systems, not call-protocol roles. Wire-format backward compatibility bullets retain "existing clients" wording (wire consumers).
  • from_openapi example: the original said the adapter maps "the OpenAPI error schema to alknet's JSON Schema format" — corrected to "the call protocol's JSON Schema format".
  • §5 closing sentences extended to note that from_openapi/to_openapi live in alkhttp and that the HTTP_<status> rule is part of this crate's gateway error-fidelity contract (ADR-047) — port framing, not a new decision.
  • Cross-reference remappings (verified alknet→alkcall ADR mapping): alknet ADR-017 (adapter contract) → alkcall ADR-022; alknet ADR-049 (streaming handler) → alkcall ADR-021. ADR-014 and ADR-015 are ported to this crate under the same numbers and linked.
  • Review/spec paths (docs/reviews/..., docs/sdd_process.md, call-protocol.md L-references) are alknet mono-repo artifacts; annotated as alknet-record citations. The call-protocol.md relative link and its line-number citation for the unknown-code rule became a textual "the alkcall crate's call-protocol.md" reference.
  • No decision content changed — the error_schemas field, the details payload field, the protocol-vs-operation code namespace split, the handler error mapping, the HTTP_<status> prefix rule, and the services/schema exposure are verbatim from the alknet ADR modulo the corrections logged above.