Tramaj — Normative Node JSON representation

On Thu, 15 Oct 2026, by @lucasdicioccio, 830 words, 7 code snippets, 0 links, 0images.

Generated from specs/node-json.md — the repository is the canonical source, and may be ahead of this page.

Tramaj — Normative Node JSON representation

The evaluated Node AST is the portable interchange boundary between Tramaj implementations and their hosts. This document defines its normative JSON representation.

An implementation MAY use any internal representation of nodes, but a conforming implementation MUST be able to produce and consume the representation below without loss: decode(encode(n)) MUST yield a node equal to n.

Object key ordering is not semantically significant and MUST NOT be relied upon.

Node AST

Annotations = Map<String, JSON>

Node
  = Text
      value: Value
      annotations: Annotations

  | Element
      tag: String
      attributes: List<NodeAttribute>
      value: Value
      children: List<Node>
      annotations: Annotations

  | Fragment
      children: List<Node>
      annotations: Annotations

NodeAttribute
  = Attribute
      name: String
      value: Value

  | Action
      event: String
      key: String
      payload: Value

Value is the ordinary JSON value domain — null, boolean, number, string, array, object — with a number being an integer or a float (see Numbers below). It is not a Node: a document tree never nests through an attribute value, an element value slot, or an action payload.

Text carries a Value, not a String, so scalars survive evaluation without implicit conversion: .p($ctx.count) with count = 3 produces a text node whose value is the integer 3, not the string "3". A host that wants a string renders one; the interchange format does not decide that for it.

Element carries a value slot alongside its children, for hosts whose target format attaches a body value to a tagged node (a scalar leaf in YAML/HCL, a configuration value, an editor model). It defaults to null when a template does not set one.

Fragment is an ordered sequence of siblings introducing no wrapper element. It is a real node in this representation; a host MAY flatten fragments when folding into its own target.

JSON encoding

Every object carries a discriminator: "type" for nodes, "kind" for attributes. Every field listed is REQUIRED — including "annotations", which is {} when a node has none, and "value", which is null when an element has no value slot. An encoder MUST emit all of them; a decoder MUST reject an object missing any.

Text

{"type": "text", "value": 3, "annotations": {}}
{"type": "text", "value": "hello", "annotations": {}}

Element

{
  "type": "element",
  "tag": "button",
  "attributes": [
    {"kind": "attribute", "name": "class", "value": "primary"},
    {"kind": "action", "event": "on-click", "key": "deploy",
     "payload": {"deployment": "web"}}
  ],
  "value": null,
  "children": [
    {"type": "text", "value": "Deploy", "annotations": {}}
  ],
  "annotations": {}
}

attributes is an ordered list, not a map: an element may carry any number of attributes and any number of actions, and their source order is preserved. Ordinary attributes and actions share one list because both are attribute-position constructs; a host reading only one kind filters on "kind".

An attribute name may repeat. The representation does not deduplicate; a host decides what a repeated name means for its target.

Fragment

{
  "type": "fragment",
  "children": [
    {"type": "text", "value": "one", "annotations": {}},
    {"type": "text", "value": "two", "annotations": {}}
  ],
  "annotations": {}
}

Numbers

Status: implemented in all six implementations (decisions.md §18), on the main branch and in no release yet. The encoder of a release from before the number split writes every number by ECMAScript’s Number::toString, so a whole-valued float is written without a fraction.

A Value number is an integer or a float (reference.md §3), and the encoding keeps the type.

  • An integer MUST be written as its decimal digits, with a - when negative, and with no fraction and no exponent: 3, -7, 100000000000.
  • A float MUST carry a fraction or an exponent. It is written as the shortest round-trip text of ECMAScript’s Number::toString, with .0 appended when that text has neither: 1.0, 0.1, 100000000000.0, 1e+21, 1e-7.
{"type": "text", "value": 3, "annotations": {}}
{"type": "text", "value": 3.0, "annotations": {}}

are two different nodes. A decoder MUST type a number by its text, by the same rule: no fraction and no exponent is an integer, either is a float, so 3e0 decodes as a float. The round-trip invariant above depends on it, so a decoder needs a JSON parser that keeps that difference, and that keeps the digits of an integer it would otherwise round.

A number is read as the reference.md §5 literal of the same text. A float has no negative zero, so -0.0 decodes as 0.0, and -0 as the integer 0; an encoder never writes either. A float too small for a double decodes as 0.0. A float too large for one (1e400) is rejected, like an integer-form number outside the integer range (see Decoding).

Every Value written as JSON follows this rule: attribute values, payloads, value slots, text values, and an expression program’s result.

Annotations

annotations is a JSON object mapping annotation keys to arbitrary JSON.

The core language assigns no meaning to any key or value. Annotations are the extension point for types, domains and constraints, provenance, host metadata, and optimization hints.

Unknown annotations MUST NOT affect core semantics, and implementations MUST preserve them across every node-to-node transformation they perform — adapt-actions in particular copies a node’s annotations through unchanged.

Decoding

A decoder MUST reject:

  • an object with no "type", or a "type" outside {text, element, fragment};
  • an attribute with no "kind", or a "kind" outside {attribute, action};
  • any object missing a field required for its discriminator;
  • a non-string tag, name, event, or key;
  • a non-array attributes or children, or a non-object annotations;
  • in a Value, an integer-form number outside its integer range (reference.md §13), which it MUST NOT round, and a float too large for a double, which it MUST NOT read as an infinity (see Numbers).

The same two refusals apply to a number in the input context, where the evaluator reports them as a TypeMismatch (reference.md §3).

A decoder MUST NOT infer a missing field from a default. Defaults are an encoder’s job; on the wire the representation is explicit, so that a missing field is a bug rather than a silent null.