Contract Boundary Spec

Summary

Libero derives a typed client/server contract from Gleam handler signatures and generates the code needed for both sides to speak that contract. Consumers use generated modules and runtime helpers. They should not need to know the wire protocol shape, raw codec details, or frame layout.

The boundary is simple: consumers call contract-level helpers instead of reaching into ETF frames, JSON envelopes, raw decoders, or hand-built request values.

The consumer should be protocol-agnostic. Whether Libero is using ETF, JSON, or another protocol later, consumer code should keep calling the same generated contract helpers. Consumers may regenerate Libero-owned modules or update facade imports, but they should not rewrite transport logic, response handling, push handling, or hydration logic because the configured protocol changed.

Different consumers can choose different protocols. Gleam and Lustre clients can use ETF. A Rust CLI, Go tool, or hand-written JavaScript client can choose JSON. Both paths come from the same Libero contract.

Ownership

Libero owns:

Consumers own:

The short rule: handlers define what messages exist. Consumers decide when to send those messages and what to do with decoded values. Libero decides how typed messages become protocol data and back.

Consumer API Shape

Consumers should call Libero through operations that name the protocol concept, not the byte format. Protocol helpers should live under matching module paths, such as libero/etf/wire and libero/json/wire.

Each protocol helper module should expose the same contract-level operations:

ConceptHelper
Encode an outbound requestencode_request
Decode an inbound requestdecode_request
Encode a response frameencode_response
Decode a server framedecode_server_frame
Encode a push frameencode_push
Encode SSR flagsencode_flags
Decode SSR flagsdecode_flags_typed

ETF may also expose lower-level frame helpers for callers that already know the frame kind. Most consumers should use decode_server_frame and let Libero inspect the frame.

Boundary Shape

The contract boundary has these parts:

Raw codec functions still exist, but they are not the framework integration path. wire.encode is documented as unsafe for user custom types unless the value has already passed through a typed encoder.

The remaining low-level surface is intentional but sharp. It should stay for tests, protocol internals, and advanced escape hatches. Framework consumers should stay on the typed helpers.

Target Frame API

Libero should expose a decoded frame type. For example:

pub type ServerFrame(value) {
  Response(request_id: Int, value: value)
  Push(module: String, value: value)
}

On JavaScript, the runtime can expose the same idea as tagged objects:

{ kind: "response", requestId, value }
{ kind: "push", module, value }

The exact representation can change. The invariant is that consumers never read tag bytes or slice request IDs out of raw frames.

Generated Modules

Generated modules should be the main consumer interface. A framework may still compose generated files into its own package, but the package should expose contract-level operations rather than raw codec operations.

Examples of generated outputs Libero may own over time:

This does not mean Libero owns framework-specific transport code. A WebSocket client can still live in a consumer framework. It should call Libero’s generated facade instead of matching frame bytes, selecting raw decoders, or building request envelopes by hand.

Security Model

The protocol boundary is where hostile or malformed input becomes typed data. Libero should make that boundary auditable.

The boundary should enforce:

The caller should receive structured protocol errors. Consumers should not parse codec exception strings.

Acceptance Criteria

Search Document