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.0appended 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, orkey; - a non-array
attributesorchildren, or a non-objectannotations; - 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.