Verified JSON boundaries

A Boundary(a, response) binds an input contract to a handler. The host calls forward with admitted JSON. Gargamelle decodes it and runs the boundary’s stored semantic requirements before invoking the handler. Rejected input returns ContractError and the handler does not run.

The handler receives two arguments: the decoded a and the original Json. If only the data is needed, ignore the second argument. The boundary decodes once; it does not construct a certificate or retain either value after the call unless application code retains them.

import gargamelle
import gleam/io
import gleam/string

// The component publishes a boundary; its handler stays private.
pub fn names_boundary() -> gargamelle.Boundary(List(String), String) {
  gargamelle.boundary(
    gargamelle.contract(gargamelle.array(gargamelle.text())),
    fn(names, _) { string.join(names, ", ") },
  )
}

pub fn main() {
  let boundary = names_boundary()
  let definition = gargamelle.boundary_definition(boundary)
  io.println(gargamelle.schema_string(definition, gargamelle.Pretty))
  let assert Ok(json) = gargamelle.parse("[\"Brainy\", \"Jokey\"]")
  let assert Ok(response) = gargamelle.forward(boundary, json)
  io.println(response)
}

This prints the array-of-strings schema followed by Brainy, Jokey. boundary_definition exposes the structural definition used for acceptance, so the host can review its schema before accepting the boundary. Through the Gleam API, the host cannot extract the handler or replace the contract within an existing boundary.

JSON-only receiving handlers

For storage or transport that needs only JSON, use json_boundary(contract, handler) and forward_json(receiver, json). The private handler receives VerifiedJson(a) after the receiver’s stored contract accepts the input. With no semantic requirements, no decoded value is constructed and no conversion callback runs. With requirements, decoding runs once to supply the checks; the decoded value is then discarded. json_boundary_contract exposes the full contract; json_boundary_definition exposes its structural definition.

import gargamelle
import gleam/io

pub fn main() {
  let receiver = gargamelle.json_boundary(
    gargamelle.contract(gargamelle.array(gargamelle.text())),
    fn(verified) {
      gargamelle.to_string(gargamelle.verified_json(verified), gargamelle.Compact)
    },
  )
  let assert Ok(json) = gargamelle.parse("[\"Brainy\", \"Jokey\"]")
  let assert Ok(text) = gargamelle.forward_json(receiver, json)
  io.println(text)
}

This prints ["Brainy","Jokey"], without constructing a decoded list.

Each submission performs one Gargamelle contract check. Admission, authorization, storage limits and other host policies are separate checks. Submission accepts Json, not a certificate from another contract. If a handler returns Result, the application result is nested inside the outer validation result. Handler exceptions and resource failures propagate as operational failures.

For typed output, encoding.output_boundary(encoder, receiver) pairs an explicit encoder with this receiving boundary. encoding.send encodes to admitted unverified JSON and calls forward_json once. The receiving contract is authoritative; there is no separate codec contract to reconcile. See Typed JSON encoding.

Semantic requirements

A contract can add checks that JSON Schema does not express. This complete example checks whether a decoded name agrees with immutable bound context:

import gargamelle
import gleam/io

pub fn main() {
  let expected = "Brainy"
  let contract = gargamelle.contract(gargamelle.text())
    |> gargamelle.require("Name agrees with the registered name", fn(name) {
      case name == expected {
        True -> Ok(Nil)
        False -> Error([gargamelle.Violation("", "Unexpected name")])
      }
    })
  let receiver = gargamelle.json_boundary(contract, fn(verified) {
    gargamelle.to_string(gargamelle.verified_json(verified), gargamelle.Compact)
  })
  let assert Ok(json) = gargamelle.parse("\"Brainy\"")
  let assert Ok(response) = gargamelle.forward_json(receiver, json)
  io.println(response)
}

This prints "Brainy". The underlying string schema also accepts "Jokey", but the full contract rejects it before the handler runs. Semantic requirements inspect decoded data and cannot repair or transform it. Requirements must be pure and total, with immutable captured context.

contract_requirements lists descriptions in declaration order. documentation.render_contract renders the structural definition followed by all requirement descriptions without running checks. Generated JSON Schema contains only the structural rules. Descriptions document intended behavior; they do not prove that callbacks implement it.

Retaining verified JSON

verify(contract, json) returns Result(VerifiedJson(a), ContractError). It validates without conversion if the contract has no semantic requirements; otherwise it decodes once and checks the decoded value. The opaque certificate retains original JSON and the exact full contract, but no decoded data. verified_json retrieves the JSON; verified_contract retrieves the full contract; verified_definition retrieves only its structural definition. These accessors execute no callbacks.

The parameter a describes an output type, not contract identity or constraints. Two Contract(String) values may accept different strings. A standalone VerifiedJson(String) therefore does not establish particular constraints. Receiving boundaries bind checks and handler invocation; forwarding always checks the recipient’s contract, even if another contract accepted that JSON.

To obtain decoded data later, call decode_contract(verified_contract(verified), verified_json(verified)), which repeats validation, conversion and semantic checks. A typed receiving boundary avoids that additional work when decoded data is needed immediately.

The original JSON is preserved as admitted. An Open record may discard extra fields from decoded data; those fields remain in the original JSON. Defaults and maps affect decoded data without rewriting JSON. Verification retains the JSON and contract, including closures captured by its definition and requirements, but no decoded tree.

Callbacks and responses

forward validates and converts once, runs semantic checks, then invokes the handler once on acceptance. Structural rejection skips conversion, requirements and the handler. Semantic rejection skips the handler. Every normal requirement result is collected in declaration order. Exceptions propagate and may stop later checks. Conversion callbacks, requirements and handlers are trusted code.

ContractError.Structural contains structural DataError values; ContractError.Semantic contains RequirementFailure values with zero-based requirement indices, descriptions and Violation(path, message) diagnostics. An empty rejection list is replaced with a root violation, so it cannot become acceptance. Paths are application-supplied JSON Pointers.

The response is whatever the handler returns. If it returns Result(response, application_error), forward returns Result(Result(response, application_error), ContractError), keeping application errors separate from input failures. It does not validate outgoing JSON or provide an encoder.

The boundary enforces the supplied contract. The host must still approve that contract and apply any additional application policy. These opaque types enforce access through Gleam’s public API; they do not isolate hostile JavaScript, Erlang or foreign-function code running in the same runtime.

✨ Search Document