Are We Drifting? — Part 5: The Error Manifest
Are We Drifting? — Part 5: The Error Manifest
Section titled “Are We Drifting? — Part 5: The Error Manifest”Part 4: Enums as Shared Vocabulary followed the base vocabulary — an enum — to its projections. An error is that same atom plus the metadata a failure needs. It is also the clearest cross-stack example in the series, because an error only does its job if it means the same thing on the backend that raised it and the frontend that handles it.
Are we drifting here?
Section titled “Are we drifting here?”A failure is a contract between two sides that never share a process.
The backend decides something went wrong. The frontend has to decide what to show, whether to offer a retry, whether to send the user somewhere. Between them is a wire, and across that wire the failure has to survive as something more useful than a 500 and a string.
Drift here is especially nasty because it is invisible until the unhappy path runs in production. The backend starts returning a new error; the frontend has a switch that does not know about it and falls through to “Something went wrong.” Or the same conceptual failure gets a 409 in one handler and a 400 in another, and the frontend’s retry logic guesses wrong. Nobody tested it, because it is the path you hope never happens.
So: is a failure declared once and understood identically on both ends? Or is it re-invented on each side and hoped into alignment?
In this codebase, three rules enforce the answer:
- A handler calls
fmt.Errorf("asset not found")directly →check-connect-errors(CI script) fails the build;connect.NewErroris only allowed inside the error-adapter package. - A handler raises
media.AssetOwnershipViolation().WithAssetID(id)→ the Connect code (permission_denied), the typed fields (asset_id), and the wire string (asset_ownership_violation) all come from the manifest; no handler invents them. - The same error crosses the wire as a structured Connect error → the frontend receives a typed code and typed fields, not a parsed HTTP status and a guessed English message.
The third rule is the FE↔BE seam that carries today. The fuller stitch — where the frontend’s error constants are also generated from the same manifest — is where this goes next.
One source
Section titled “One source”Here is a real error vocabulary from the media module. Three failure modes, declared once:
kind: erroroutput: ".."module: medianame: MediaErrorsentries: - name: UnsupportedContentType wire: unsupported_content_type label: Unsupported content type metadata: code: invalid_argument fields: [content_type]
- name: UploadTooLarge wire: upload_too_large label: Upload exceeds size limit metadata: code: invalid_argument fields: [asset_id, size_bytes, max_bytes]
- name: AssetOwnershipViolation wire: asset_ownership_violation label: Asset does not belong to requesting user metadata: code: permission_denied fields: [asset_id]It is the same shape as the enum from Part 4 — name, wire, label — with two pieces of failure-specific metadata per entry: a transport code (a Connect error code) and the typed fields this error carries as structured context.
The backend projection
Section titled “The backend projection”buildmere generates a zero-import Go type per error. This is the actual generated code for the first one — note that it imports nothing but fmt:
// Code generated by buildmere; DO NOT EDIT.
package media
import "fmt"
// UnsupportedContentTypeError is the typed error for unsupported_content_type.// Wire: "unsupported_content_type" | Code: "invalid_argument"type UnsupportedContentTypeError struct { contentType string cause error msg string}
func UnsupportedContentType() UnsupportedContentTypeError { return UnsupportedContentTypeError{} }
func (e UnsupportedContentTypeError) WithContentType(v string) UnsupportedContentTypeError { e.contentType = v return e}
func (e UnsupportedContentTypeError) Wire() string { return "unsupported_content_type" }func (e UnsupportedContentTypeError) Code() string { return "invalid_argument" }func (e UnsupportedContentTypeError) Fields() map[string]any { return map[string]any{"content_type": e.contentType}}func (e UnsupportedContentTypeError) Error() string { if e.msg != "" { return e.msg } return "unsupported content type"}func (e UnsupportedContentTypeError) Unwrap() error { return e.cause }func (e UnsupportedContentTypeError) Is(target error) bool { t, ok := target.(interface{ Wire() string }) return ok && t.Wire() == e.Wire()}func (e UnsupportedContentTypeError) Wrap(err error) error { e.cause = err; return e }A handler raises it fluently, with the typed field attached as structured context:
return media.UploadTooLarge(). WithAssetID(id). WithSizeBytes(strconv.FormatInt(n, 10)). WithMaxBytes(strconv.FormatInt(max, 10))The Code() — invalid_argument, permission_denied — is the value that crosses the wire. The generated struct is deliberately inert: it knows its wire string, its transport code, and its fields, and nothing about Connect. A small adapter the project owns turns a coded error into a connect.NewError(...) at the transport boundary — and in our repo a CI check (check-connect-errors) enforces that connect.NewError appears only in that one adapter package, never scattered through handlers. One place translates application failures into wire failures.
The frontend projection — and where this goes
Section titled “The frontend projection — and where this goes”Here is the side-by-side.
What the wire carries: the frontend learns about the failure through the contract. The error crosses the wire as a structured Connect error carrying the code and the typed fields from the manifest. The generated Connect client surfaces it as a typed error, so the frontend is reasoning about permission_denied and a content_type field — not parsing a 403 and a sentence of English. That is a real cross-boundary coupling: the backend declared the failure once, and the frontend receives a structured, coded shape rather than a guess.
The next emitter: the philosophy this manifest is built toward goes one step further — generating the frontend’s error vocabulary from the same source. From one declaration you also emit:
// the frontend projection from the same manifestexport const MediaError = { UnsupportedContentType: "unsupported_content_type", UploadTooLarge: "upload_too_large", AssetOwnershipViolation: "asset_ownership_violation",} as const;…plus safe user-facing messages, a retryable hint, and a category so the UI can branch exhaustively on the error code instead of on an HTTP status. At that point a backend engineer adding an error and a frontend engineer handling it are reading projections of the same row, and a missing case is a compile error on the frontend.
buildmere’s error kind emits Go; the TypeScript emitter is the natural extension — the kernel is built for exactly this, since adding a language to a kind is a new generator package. The backend half ships and is gated, the wire-level coupling ships through the contract, and the generated frontend half is the next emitter.
A design principle worth stealing now
Section titled “A design principle worth stealing now”One rule from the error philosophy is worth applying by hand right now: the application error code is the source of truth, and transport codes derive from it — never the reverse.
It is tempting to let HTTP status be the canonical thing. But the mappings are not one-to-one. HTTP 409 can mean “already exists” or “version conflict”; HTTP 400 can mean “invalid argument” or “failed precondition.” If you make HTTP the source, you lose that distinction on the way in and guess it back on the way out.
An error code names the application failure. HTTP, gRPC/Connect, an MCP tool response, a CLI exit, and a frontend display are all renderings of it. The code owns its mappings; no transport is the universal source of truth.
Our manifest carries the Connect code directly on each entry for this reason — the failure’s meaning travels with the failure, and each edge decides how to render it.
The velocity payoff
Section titled “The velocity payoff”The coherence check this removes is the one that only fails in production: does the frontend’s handling of this failure match the failure the backend actually emits?
Today that is partly structural (the code and fields cross the wire as a typed contract, not a parsed status) and partly still manual (the frontend’s message and branching are hand-written). The frontend emitter closes the gap entirely. Either way, the direction is the one this series argues: move the failure’s definition into one declared place, and let each side’s handling be generated or contract-checked rather than independently authored and hoped into agreement.
What’s next
Section titled “What’s next”Part 6: Config and Metrics finishes the vocabularies arc with the two kinds that govern a service’s edges rather than its data — config (the environment a service trusts) and metrics (the signals it emits) — both declared as the same atom, both gated the same way.