Designing API Messages
An API surface’s wire contract is determined directly by the Hale types used in its rpc rows. By choosing specific record field patterns, enum shapes, and error class annotations, you control how the compiler encodes JSON and Protobuf payloads, validates requests, generates OpenAPI/JSON Schema specifications, and maps domain errors to HTTP and gRPC status codes.
role operator;
type Status = enum { Up, Degraded(String), Down };type Check { name: String; status: Status; }
type HealthQuery { verbose: Bool = false; since: Time = `1970-01-01T00:00:00Z`; has_since: Bool = false;}
type Health { checks: bounded[Check; 16] indexed_by name; note: String = ""; has_note: Bool = false;}
type Restart { name: String; }type Problem = enum { @not_found NotFound(String), @unavailable Busy };
api Ops requires: [operator] { rpc Probe::health requires: []; rpc Probe::restart;}Wire Modeling Patterns
Section titled “Wire Modeling Patterns”| Pattern | Hale Declaration | Wire Representation & Behavior |
|---|---|---|
| Field Default | verbose: Bool = false; |
Omitted from JSON Schema required. If omitted by the caller, the decoder substitutes the default value. Defaults may be literals, enums (Scope::key), or records, and may reference module constants/items. |
| Presence Pair | since: Time = ...;has_since: Bool = false; |
Distinguishes “omitted or null” (has_since == false) from a provided value (has_since == true). has_since is never sent on the wire; when encoding, the field is omitted if has_<field> is false. |
| Keyed Map | checks: bounded[Check; 16] indexed_by name; |
Stored in Hale as an ordered bounded[Check; 16] list, but encoded on the wire as a JSON object keyed by each element’s name field ({"db": {"status": "Up"}}). |
| Enum | enum { Up, Degraded(String), Down } |
Unit variants encode as strings ("Up"). Payload variants encode as single-key objects ({"Degraded": "cold"}). |
| Error Class | @not_found NotFound(String) |
Maps fallible(E) enum variants to transport status codes (404 / gRPC NOT_FOUND) and sets the Hale-Error-Class response header. |
| Surface Default Role | api Ops requires: [operator] { ... } |
Sets the default requires list for every row in the surface. Individual rows can override it (e.g., requires: [] to allow any authenticated caller). Produces the exact same contract digest as spelling roles on each row. |
Error Class Status Mappings
Section titled “Error Class Status Mappings”When a handler fails with a fallible(E) enum variant, the variant’s decorator selects the transport status code:
| Decorator | HTTP Status | gRPC Status | Hale-Error-Class Header |
|---|---|---|---|
| (none) | 422 Unprocessable Content |
FAILED_PRECONDITION |
(omitted) |
@not_found |
404 Not Found |
NOT_FOUND |
not_found |
@conflict |
409 Conflict |
ABORTED |
conflict |
@invalid |
422 Unprocessable Content |
INVALID_ARGUMENT |
invalid |
@forbidden |
403 Forbidden |
PERMISSION_DENIED |
forbidden |
@unavailable |
503 Service Unavailable |
UNAVAILABLE |
unavailable |
Writing the Handlers
Section titled “Writing the Handlers”Handlers operate on decoded Hale structs and enums without manual JSON parsing:
/// Builds a health snapshot (stand-in for live dependency probes).fn snapshot(n: Int) -> Health { let mut h = Health { }; push(h.checks, Check { name: "db", status: Status::Up }) or discard; if n % 2 == 0 { push(h.checks, Check { name: "cache", status: Status::Degraded("cold") }) or discard; } else { push(h.checks, Check { name: "cache", status: Status::Up }) or discard; } return h;}
locus Probe { params { restarts: Int = 0; } fn health(q: HealthQuery) -> Health { let mut h = snapshot(self.restarts); if q.has_since { h.note = "changes since " + std::time::iso8601(q.since); h.has_note = true; } return h; } fn restart(r: Restart) -> Health fallible(Problem) { if r.name != "db" && r.name != "cache" { fail Problem::NotFound(r.name); } self.restarts = self.restarts + 1; return snapshot(self.restarts); }}Inspecting Generated Schemas
Section titled “Inspecting Generated Schemas”Run hale check --api with --json-schema to verify how your message types project to external schemas without starting the service:
hale check --api status.hl --surface Ops --json-schema"checks": { "type": "object", "additionalProperties": { "type": "object", "properties": { "status": { "oneOf": [ … ] } }, … }, "maxProperties": 16, "x-hale-key": "name"},"note": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }…"error": { "oneOf": [ { "type": "string", "enum": [ "Busy" ] }, { "type": "object", "properties": { "NotFound": { "type": "string" } }, "required": [ "NotFound" ], "additionalProperties": false } ], "x-hale-type": "Problem", "x-hale-error-classes": { "NotFound": "not_found", "Busy": "unavailable" }}checksis described as an object withmaxProperties: 16and"x-hale-key": "name".noteis optional and nullable (stringornull).Problemlists its variants alongside"x-hale-error-classes".
Serving and Exercising the Wire Contract
Section titled “Serving and Exercising the Wire Contract”Bind Ops to an http::Rpc exposure with bearer authentication and a static role table:
locus Tokens { fn principal(token: String) -> std::api::Principal { if token == "t-ops" { return std::api::Principal { mode: "bearer", name: "ops" }; } return std::api::Principal { mode: "bearer", name: "" }; } fn refused() -> String { return "no such token"; }}
main locus App { params { probe: Probe = Probe { }; tokens: Tokens = Tokens { }; staff: std::api::StaticRoles = std::api::StaticRoles { known: "operator", table: "operator=bearer:ops" }; web: api::Exposure = api::serve(Ops, http::Rpc { bind: "127.0.0.1:8794", codec: json, principals: self.tokens, roles: self.staff }, as: "web", receivers: { Probe: self.probe }, bound: 64, on_full: refuse); } run() { while !self.draining { std::time::sleep(100ms); } self.web.stop(); }}
fn main() { App { };}Inspect the served surface with hale describe:
$ hale describe http://127.0.0.1:8794 --bearer t-opsOps@fnv1a64:95d08b7e7dd9f4de/web [http 127.0.0.1:8794]caller: bearer ops; roles: operator Probe::health(since?: String (Time) or null, verbose?: Bool) -> Health requires: - Probe::restart(name: String) -> Health, error Problem requires: operatorPresence Pair & Timestamp Validation
Section titled “Presence Pair & Timestamp Validation”Sending different payloads to POST /call/Probe::health (with Authorization: Bearer t-ops) demonstrates how presence pairs and strict timestamp parsing behave:
{} 200 {"checks":{"db":{"status":"Up"},"cache":{"status":{"Degraded":"cold"}}}}{"since": "2026-10-18T09:00:00Z"} 200 {"checks":{…},"note":"changes since 2026-10-18T09:00:00Z"}{"since": null} 200 {"checks":{"db":{"status":"Up"},"cache":{"status":{"Degraded":"cold"}}}}{"since": "2026-10-18T09:00:00+01:00"} 400 {"refusal":{"kind":"malformed","reason":"wrong_type: since"}}- Omitting
sinceor sending{"since": null}leaveshas_sinceset tofalse, soh.has_notestaysfalseandnoteis omitted from the response. Timevalues on the wire must be UTC RFC 3339 strings ending inZ. Non-UTC offsets (+01:00) are rejected at the transport boundary with400(wrong_type: since).
Distinguishing Classified Handler Errors from Transport Refusals
Section titled “Distinguishing Classified Handler Errors from Transport Refusals”When Probe::restart fails with Problem::NotFound("queue"), the exposure responds with HTTP 404 and the Hale-Error-Class: not_found header:
$ curl -si -X POST localhost:8794/call/Probe::restart -H 'Authorization: Bearer t-ops' -d '{"name": "queue"}'HTTP/1.1 404 Not FoundContent-Type: application/jsonCache-Control: no-storeHale-Error-Class: not_foundContent-Length: 20Connection: close
{"NotFound":"queue"}Because some error classes share HTTP status codes with transport refusals (403, 409, 503), the Hale-Error-Class header unambiguously marks the response as a domain handler error rather than an exposure refusal. Both hale call and generated clients inspect this header automatically:
$ hale call http://127.0.0.1:8794 Probe::restart --name queue --bearer t-opshandler error (not_found): {"NotFound":"queue"}hale call exits with code 1 (handler error). Over a Unix socket, the reply line includes "class": "not_found" alongside the error payload; over gRPC, the call returns NOT_FOUND with the encoded error in grpc-status-details-bin.
Evolving a Surface Without Breaking Clients
Section titled “Evolving a Surface Without Breaking Clients”Every api surface computes a surface digest over all of its rows, plus an individual member digest for each rpc row (spec/api.md § The contract digest). Generated clients send the member digest on every request, and the exposure admits any request matching either the current surface digest or that specific member’s digest.
Adding a new rpc row changes the overall surface digest, but leaves existing member digests untouched:
before: Ops fnv1a64:95d08b7e7dd9f4de Probe::health fnv1a64:38d1eff02ba6e98c Probe::restart fnv1a64:36f121c52d2c64b9
after adding rpc Probe::uptime requires: []; Ops fnv1a64:1ccf03bbfaa228b8 Probe::health fnv1a64:38d1eff02ba6e98c Probe::restart fnv1a64:36f121c52d2c64b9 Probe::uptime fnv1a64:f233238323e2aec6Existing clients calling Probe::health or Probe::restart continue working without regeneration. Conversely, if you rename or retype a field in Health or change an error class on Problem, the member digest changes and outdated clients are rejected with digest_mismatch.
Streaming Wire Records as Server-Sent Events (SSE)
Section titled “Streaming Wire Records as Server-Sent Events (SSE)”Every record and enum eligible for an API surface provides a compiler-generated <Type>::api_encode method. You can use T::api_encode inside a standard std::http::Server handler to stream Server-Sent Events using the exact same JSON wire format as your RPC surface:
/// Streams three health snapshots as Server-Sent Events, one per second.locus Watch { fn handle(req: std::http::Request) -> std::http::Response { let w = std::http::SseWriter { conn_fd: req.conn_fd }; let mut n = 0; while n < 3 { let ev = std::http::SseEvent { event: "health", id: to_string(n), data: Health::api_encode(snapshot(n)) }; w.event(ev) or { break; }; std::time::sleep(1s); n = n + 1; } return w.end(); }}Declare the HTTP server as a parameter on App, place it on an async_io pool so each SSE connection parks on its own coroutine, and configure max_request_bytes and too_large to cap incoming request sizes:
events: std::http::Server = std::http::Server { port: 8795, handler: Watch { }, max_request_bytes: 4096, too_large: std::http::Response { status: 413, content_type: "application/json", body: "{\"error\":\"request too large\"}" },};// …placement { events: cooperative(pool = events) where async_io; }Connecting with curl -sN streams the Health::api_encode payloads:
$ curl -sN localhost:8795/event: healthid: 0data: {"checks":{"db":{"status":"Up"},"cache":{"status":{"Degraded":"cold"}}}}
event: healthid: 1data: {"checks":{"db":{"status":"Up"},"cache":{"status":"Up"}}}
event: healthid: 2data: {"checks":{"db":{"status":"Up"},"cache":{"status":{"Degraded":"cold"}}}}If the client disconnects early, w.event(ev) fails, breaking out of the loop and closing the stream via w.end(). Any request exceeding max_request_bytes (e.g., a 5,000-byte request) is rejected before reading the body:
HTTP/1.1 413 Content Too LargeContent-Type: application/jsonContent-Length: 29Connection: close
{"error":"request too large"}Related Reference
Section titled “Related Reference”- Codec Shapes, Digests & Error Classes: The API Surface and
spec/api.md(§ Codecs, § The contract digest, § Error classes). - HTTP Streaming & Request Caps: HTTP and
spec/stdlib.md(std::http). - Style Guide Rules:
spec/styleguide.md(§ 2.11 for API message shapes, C10 for HTTP edge limits).