Surfaces and projections
CX has one canonical surface (the bracket form) and projections
for JSON, YAML, TOML, XML, Markdown, and CSV/TSV/PSV.
Every projection is defined as a transformation of canonical CX.
The normative rules live in
spec/03-approved/core/canonical.md and
spec/03-approved/core/conversions.md; this section
is the developer-facing working summary.
CX — the canonical dialect
CX has two canonical forms — one for source-of-truth
formatting (preserves comments, anchors, presentation), one for
content-addressing (strips presentation, expands aliases,
normalizes everything that doesn't change data). Per
spec/03-approved/core/canonical.md §1.
Lossless canonical (cx fmt)
Lossless canonical (cx fmt) preserves every node: comments,
anchors, aliases, merges, CX directives, BlockContent,
RawText, processing instructions. It normalizes only
presentation: whitespace, indent, quoting, number
formatting.
- Idempotent:
fmt(fmt(x)) == fmt(x)for any validx. - Use cases: opinionated source-file formatter; diff stability across edits that don't change data; code review where reviewers see only meaningful changes.
input.cx
# a note about the user
[user id=1 name="Alice"]
$ cx --from=cx --to=cx input.cx
# a note about the user
[user id=1 name=Alice]
Fixture abi-001-fmt-lossless-retains-comment in deps/cx-core-code/conformance/abi.cxd — replayed against this cx when the page was built.
Strict canonical (cx canonical / cx hash)
Strict canonical (cx canonical) reduces input to
data-equivalence form. Strips comments and directives,
expands anchors and aliases, resolves merges, normalizes
datetime offsets to UTC, renames declared ids to a
deterministic document-order scheme. Two CX documents
have identical strict canonical bytes if and only if they
encode the same data. Attribute order is data — it is
preserved, never sorted.
- Use cases:
cx hash(content-addressable hashing — SHA-256 of strict canonical bytes); signed configuration (sign the strict canonical bytes; verify after canonicalizing); deduplication keyed on data, not file bytes. - Strict canonical is what the identity story rests on. Two documents that "encode the same data" but differ on comments, whitespace, quoting, or anchor names still hash to the same bytes.
prog.cx
[$cx:canonical [$cx:parse "[user
[; regrettably noisy comment ]
email='ada@example.com'
id=42
active=true]"]]
$ cx prog.cx
'[user email=ada@example.com id=42 active=true]\n'
Fixture cx-076-canonical-strips-comment-keeps-attr-order in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
The strict-canonical bytes are what the SHA-256 hash is taken over (see hash) and what the round-trip guarantee is checked against (see roundtrip).
JSON projection
JSON is the most common interchange surface. CX → JSON mapping
is straightforward; JSON → CX is well-defined for the JSON
subset that maps cleanly to CX's data model. Per
spec/03-approved/core/canonical.md §5 and
spec/03-approved/core/conversions.md §2.2 / §4.
prog.cx
[$cx:to-format [user id=42 email='ada@example.com' active=true [roles [role admin] [role editor]]] "json"]
$ cx prog.cx
'{\n "user": {\n "id": 42,\n "email": "ada@example.com",\n "active": true,\n "roles": {\n "role": [\n "admin",\n "editor"\n ]\n }\n }\n}'
Fixture cx-075-to-format-json-nested-record in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Basic mapping: Attributes become object keys; element bodies of pure-element content become nested objects keyed by child element name; repeated identically-named children become an array; scalars map directly.
Mixed content and the lossless $tag envelope
Mixed content — text interleaved with inline elements — has
no native JSON form. The default projection is lossy: the
text runs collect into an array under a reserved "_" key and
the inline elements become sibling keys, so run order is not
recoverable. Under --lossless, an element instead emits as
the reserved $tag envelope
(spec/03-approved/core/conversions.md §2.2.1):
$children is an ordered array of runs, so mixed content
survives exactly.
prog.cx
[$cx:to-format [para 'The function ' [code 'parse'] ' returns a value.'] "json"]
$ cx prog.cx
'{\n "para": {\n "_": [\n "The function ",\n " returns a value."\n ],\n "code": "parse"\n }\n}'
Fixture cx-072-to-format-json-mixed-content-default in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
JSON → CX reverses this unconditionally: a $tag envelope
reconstructs the element with its body in order. The
round-trip is byte-identical against cx canonical output
(cx eq accepts the recovered document); the envelope also
carries attributes ($attrs + $attr-types), element
metadata ($anchor / $merge / $id / $type), and
[table] payloads ($cols / $rows).
Namespace handling
CX namespace declarations (xmlns:dc=URI) project as JSON
object keys with the xmlns: prefix preserved. Qualified
names like dc:title project as JSON keys with the colon
preserved (JSON keys are arbitrary strings, so this is
legal). Round-trip is lossless when consumers preserve the
xmlns: attributes.
Number precision and BigInt
JSON numbers are IEEE-754 doubles per RFC 8259, with
ambiguous integer support beyond 2^53-1. CX exports int64
integers as JSON numbers; bigint scalars export as JSON
strings, with the type recovered in lossless mode via the
cx:type sidecar (map positions) or the per-item
{"cx:bigint": "…"} carrier (array positions) — see
spec/03-approved/core/conversions.md §0.2.
Sized variants (::i32, ::u64) keep their native number
image; the width name rides the envelope's $type /
$attr-types in lossless mode.
Floats with nan / inf / -inf are not reachable from
pure CX arithmetic (CX floats are finite-only); at the FFI
boundary the JSON module's nan-handling option controls
null / string / error behavior.
null vs missing
JSON has no way to distinguish "key absent" from "key present
with value null." CX preserves the distinction where JSON can
express it: [user email=null] projects to
{"user": {"email": null}}, while an empty element [user]
projects to {"user": null} (an element with no attributes
and no body has nothing to become an object of). JSON → CX
reads absent keys as absent attributes (NOT as null).
YAML projection
YAML follows the JSON projection but uses YAML's mapping and sequence syntax — attributes and nested elements are mappings, repeated children a sequence:
prog.cx
[$cx:to-format [service host='localhost' port=8001 tls=true [routes [route path='/health'] [route path='/metrics']]] "yaml"]
$ cx prog.cx
'service:\n host: localhost\n port: 8001\n tls: true\n routes:\n route:\n - path: /health\n - path: /metrics'
Fixture cx-069-to-format-yaml-nested-record in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
The lossless $tag envelope is the same encoding in both projections
(spec/03-approved/core/conversions.md §2.3.1):
under --lossless, CX anchors and merges ride the envelope's
$anchor / $merge keys.
YAML's loose schema (untyped scalars, multiple ways to spell
the same value) is normalized on input: yes/true/True
all map to canonical true, numeric strings stay strings,
etc. The conversion is documented in
spec/03-approved/core/conversions.md §5.
Multiline scalars (| and >)
On input, YAML's literal (|) and folded (>) block styles
parse per YAML 1.2 — newlines preserved or folded
respectively. On emit there is no block-scalar form:
the canonical YAML emitter is block-style for structure but
writes multi-line string values as ordinary quoted scalars
with escaped newlines (body: "line one\\nline two"), which
round-trip exactly.
Anchors and aliases
YAML-native anchors (&name) and aliases (*name) are
resolved (expanded) on YAML → CX parse — the data content is
preserved, the anchor/alias structure is not
(spec/03-approved/core/conversions.md §5.1).
In the other direction the default YAML emit is symmetric:
CX anchors and merges are resolved — aliases expand to
their referent, merge hosts inherit — so the YAML carries the
semantic document (the Resolved AST, ast.md). The lossless
envelope instead carries the authored structure via $anchor
/ $merge keys, which the importer reconstructs — so a
--lossless round-trip preserves them.
TOML projection
CX → TOML projects the element tree onto TOML tables: each
nested element becomes a section whose header is the
quoted dotted path from the root, and repeated
identically-named children become an array of tables. Per
spec/03-approved/core/conversions.md §2.4 / §6.
prog.cx
[$cx:to-format [config [server host='localhost' tls=true] [users [user id=1 email='ada@example.com'] [user id=2 email='grace@example.com']]] "toml"]
$ cx prog.cx
'[config]\n["config.server"]\nhost = "localhost"\ntls = true\n\n\n["config.users"]\n[["config.users.user"]]\nid = 1\nemail = "ada@example.com"\n\n\n[["config.users.user"]]\nid = 2\nemail = "grace@example.com"\n\n\n\n'
Fixture cx-068-to-format-toml-sections in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Section headers, not inline tables
TOML allows three spellings for nested scalars: inline tables
(server = { host = "x" }), dotted keys (server.host = "x"),
and section headers. All three parse; the emitter's choice is
structural, not size-based — there is no entry-count
threshold. Every nested element emits as a section with a
quoted dotted-path header (["config.server"]), scalars as
key = value lines beneath it.
Array of tables
[[users]] repeated on the TOML side produces an array of
maps on import (TOML → CX yields map / array values, not
elements). CX → TOML emits [["path.to.name"]] — the
quoted-dotted-path array-of-tables header — for any sibling
group of identically-named child elements, preserving
order; a one-of-a-kind child emits a plain quoted section
header, never a dotted key.
XML projection
XML is the closest projection — both CX and XML are
element-with-attributes-and-body languages. The mapping is
essentially one-to-one for element / attribute / text content.
Per spec/03-approved/core/canonical.md §8 and
spec/03-approved/core/conversions.md §2.1 / §3.
prog.cx
[$cx:to-format [user id=42 email='ada@example.com' [profile [bio 'Computing pioneer.']]] "xml"]
$ cx prog.cx
'<user id="42" email="ada@example.com">\n <profile>\n <bio>Computing pioneer.</bio>\n </profile>\n</user>'
Fixture cx-077-to-format-xml-nested-record in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Differences from XML: CX has no DTD validation layer (schemas
are first-class CX, see schema); namespace declarations
are CX attributes with the xmlns: prefix preserved; CDATA
sections project to CX raw-text nodes ([#…#]), whose
contents are opaque bytes.
Processing instructions and comments
XML processing instructions (<?xml-stylesheet …?>) project
to CX directive elements ([?xml-stylesheet …]). XML
comments (<!-- … -->) project to CX comments ([; … ]).
Both round-trip losslessly in the lossless canonical form;
the strict canonicalizer strips comments per
canonical-strict.
xml:space round-trip
xml:space=preserve survives XML round-trip identically —
as an inert pass-through attribute. CX gives it no
engine semantics: quoted body text is verbatim anyway (see
xml-space), so there is nothing for the attribute to
switch. It rides along for XML consumers that honor it.
DOCTYPE and DTD
CX does not have a DTD layer. XML input with a DOCTYPE
declaration is accepted; the declaration is captured as a
DoctypeDecl prolog node on the document (<!DOCTYPE html>
imports as [!DOCTYPE html] ahead of the root element).
DTD internal-subset content (<!ELEMENT>, <!ATTLIST>,
<!ENTITY>) is carried verbatim inside that node and is
not used for validation — schemas are first-class CX
(.cxs files, see schema).
Markdown projection
Markdown is a typed-lossy codec, not one of the lossless
data projections (spec/03-approved/core/conversions.md §7).
MD → CX yields a [doc]-rooted prose tree: headings become
[h1] [h2] …; paragraphs become [p]; lists become [ul] /
[ol] / [li]; inline code becomes [code]. The MD → CX → MD
direction is byte-stable for the supported subset, but
CX ↔ MD in general is lossy — element names, attributes, and
types outside the prose vocabulary do not survive.
prog.cx
[$cx:from-format "# Hello
A paragraph with *emphasis* and `code`.
- item 1
- item 2" "md"]
$ cx prog.cx
[doc [h1 Hello] [p 'A paragraph with ' [em emphasis] ' and ' [code code] '.'] [ul [li item 1] [li item 2]]]
Fixture cx-074-from-format-md-prose-tree in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
GFM tables
A GFM pipe table in Markdown input is not decomposed into
[tr] / [th] / [td] structure — the table's source lines
are carried as raw text inside a [table] element, and
re-emit reproduces them byte-for-byte.
In the other direction, a CX [table[…]] block (see
tables) emits as a real GFM pipe table — header row from
the declared column names, then one row per data row — per
spec/03-approved/core/conversions.md §7:
prog.cx
[$cx:to-format [$cx:parse "[doc [p 'Two people:'] [table [table[name age::int]]
Ada 35
Grace 36
]]"] "md"]
$ cx prog.cx
'Two people:\n\n| name | age |\n| --- | --- |\n| Ada | 35 |\n| Grace | 36 |'
Fixture cx-079-to-format-md-table-block-is-gfm in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Fenced code with language
Fenced code blocks ( lang ... ) project to a
[code] element with a bare lang attribute and the code
carried verbatim as raw text — [code lang=v
[#println(1)#]]. There is no [pre] wrapper. The language
identifier round-trips.
Frontmatter
YAML frontmatter (between --- markers at top of file)
projects to attributes on the [doc] root element.
Values stay strings — a frontmatter date: is not
auto-typed into a CX date.
prog.cx
[$cx:from-format "---\ntitle: My Post\ndate: 2026-05-22\n---\n\nHello, world." "md"]
$ cx prog.cx
[doc title='My Post' date='2026-05-22' [p 'Hello, world.']]
Fixture cx-071-from-format-md-frontmatter in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Footnotes
GFM footnote syntax (text[^1] … [^1]: ref) is not
implemented — there are no [fn-ref] / [fn-def]
elements. Footnote markers are treated as ordinary inline
text by the prose parser and do not round-trip as
footnotes. Keep footnotes out of Markdown that must
survive a CX round-trip.
Math blocks
Math syntax ($inline$, $$display$$) is not
implemented — there is no [math] element. Dollar-sign
math is treated as ordinary inline text and is not
protected from the prose parser's emphasis and pairing
rules. Keep LaTeX math out of Markdown that must survive
a CX round-trip.
Delimited projection (CSV / TSV / PSV)
CSV (comma-separated), TSV (tab-separated), and PSV
(pipe-separated) all project via the same delimited-data
pipeline. Per
spec/03-approved/core/canonical.md §9 and
spec/03-approved/core/conversions.md §8.
Delimited round-trip is well-defined but lossy (column
types are not preserved on emit, only on the in-memory
CX side).
prog.cx
[$cx:from-format "id,name,email,active
1,Ada,ada@example.com,true
2,Grace,grace@example.com,false" "csv"]
$ cx prog.cx
[table [table[id::int name email active::bool]]
1 Ada ada@example.com true
2 Grace grace@example.com false
]
Fixture cx-070-from-format-csv-typed-table in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Header row
The first row is always the header — its cells become
the column names of the imported [table[…]] block. There
is no header-detection heuristic and no flag to declare the
header absent; headerless data needs a header row added
before import.
Type inference
Per-column type inference scans the whole column: all
values match int → ::int; all match int or float →
::float; all bool → ::bool; all date or datetime →
::date / ::datetime; otherwise the column stays string
(the untyped default). One odd value anywhere in the column
is enough to keep it string — inference never truncates its
scan.
Quoting and escaping
RFC 4180 rules: fields containing the delimiter, double
quote, or newline are wrapped in double quotes; internal
double quotes are escaped as "". Output canonicalizes to
RFC 4180 regardless of input quoting style. Lone CR / LF
outside quotes are treated as row terminators; embedded
CRLF inside a quoted field is preserved verbatim.
CSV → CX → CSV is byte-identical for RFC-4180-conformant
input (quoted fields and escaped quotes included). The
emit direction is defined only for table-shaped documents:
a CX document that is not a single flattenable table
refuses with a named emit_delimited error rather than
guessing a tabular shape.
prog.cx
[$cx:to-format [$cx:parse "[table [table[id::int name email active::bool]]
1 Ada ada@example.com true
2 Grace grace@example.com false
]"] "csv"]
$ cx prog.cx
'id,name,email,active\r\n1,Ada,ada@example.com,true\r\n2,Grace,grace@example.com,false\r\n'
Fixture cx-073-to-format-csv-from-table in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.
Bytes scalar across surfaces
The :bytes scalar kind (see bytes) has a different wire
form per surface — no surface has a single native binary form
that all the others share. The CX↔surface bridge applies a
deterministic encoding per surface:
| surface | wire form | notes |
|---|---|---|
| CX (canonical) | 0x<hex> | hex digits, even count; the only literal form |
| data-bin | length-prefixed raw | no encoding overhead |
| ast-bin | length-prefixed raw | same as data-bin |
| JSON | string (base64) | lossless adds the cx:type sidecar; JSON has no native bytes |
| YAML | string with !!binary tag | YAML 1.2 binary tag |
| XML | hex text + cx:type="bytes" | the sidecar attribute is emitted unconditionally, not only under --lossless |
| TOML | string (base64) | TOML has no binary type |
| Markdown | HTML-comment fallback | a bytes-bearing element has no prose image; it emits as an HTML comment |
| CSV / TSV | 0x<hex> text in the cell | deserializer needs schema to know it is bytes |
| CXCol / Parquet | binary column type | native; zero-copy through the Arrow bridge |
The table states the shipped behavior per projection: JSON / TOML
emit base64 strings, YAML emits !!binary, XML emits the hex
text with the cx:type="bytes" sidecar, and the lossless
JSON projection recovers the bytes type via the cx:type sidecar /
{"cx:bytes": …} carrier.
Round-trip guarantees and lossiness matrix
The round-trip contract is the single most important property of CX's surface design. Stated formally:
For any CX document D in canonical form, and any
lossless projection format F (XML, or JSON / YAML
under --lossless), the following identity holds:
to_cx(to_F(D)) == D (as canonical bytes)
For the lossy projections the loss is documented and bounded — see
the matrix below and
spec/03-approved/core/conversions.md.
The conformance suite includes round-trip fixtures for every
supported projection; the runner shells out to
cx --from=… --to=… in both directions and asserts byte
equality (or documented-divergence) per format.
Lossiness matrix (reproduced from spec/03-approved/core/conversions.md §9):
| from \ to | CX | XML | JSON | YAML | TOML | MD | CSV |
|---|---|---|---|---|---|---|---|
| CX | lossless | lossless‡ | lossy◊ / lossless▣ | lossy◊ / lossless▣ | lossy◊ | lossy◊ | lossy§ |
| XML | lossless | lossless | lossy◊ | lossy◊ | lossy◊ | lossy | lossy§ |
| JSON | adds struct | adds struct | lossless¶ | lossless¶ | lossless¶ | lossy | lossy§ |
| YAML | lossy\* | lossy\* | lossless¶ | lossless¶ | lossless¶ | lossy | lossy§ |
| TOML | lossless\*\* | lossless\*\* | lossless¶ | lossless¶ | lossless¶ | lossy | lossy§ |
| MD | lossy | lossy | lossy | lossy | lossy | lossless | lossy§ |
| CSV | lossy§ | lossy§ | lossy§ | lossy§ | lossy§ | lossy§ | lossless |
Legend:
- lossless — byte-identical round-trip within the format's expressive range.
- ‡ — CX → XML is lossless when consumers preserve the
cx:namespace attributes. - ¶ — lossless within the expressive range of that format.
- ▣ — lossless under
--lossless: the$tagstructure envelope + value carriers (spec/03-approved/core/conversions.md§2.2.1 / §2.3.1) recover element documents byte-identically (strict-canonical eq). The default (idiomatic) projection keeps the ◊ losses. Program directives are outside every projection's lossless domain. - ◊ — CX → JSON/YAML/TOML/MD default projection with CXDM
container Items (Array, Map, Sequence-as-Item):
non-string Map keys are coerced to strings on emit
(lossy); the Sequence-vs-Array distinction is lost
(in lossless mode both recover, via the
cx:key-typesidecar and thecx:seqcarrier). - § — Delimited (CSV/TSV/PSV) is well-defined but lossy: type metadata is lost on emit; anchors, aliases, and merges are resolved (an aliased row projects as a full row); comments, multi-document, mixed content, and PIs are stripped or error; element names are lost on parse (recovered via schema or auto-typing — see delim-types).
- \\* — YAML anchors and aliases are resolved (expanded) on parse; not reconstructed in CX output. Data content is preserved.
- \\*\\* — TOML has no mixed content or comments, so CX output contains only what TOML can express; no loss within TOML's range.
Practically: you can store data as JSON for the JS ecosystem, convert to CX for selection / transformation / hashing, then convert back to lossless JSON for delivery — no information loss, no re-formatting drift, no string-mangling in between. The CSV / TSV / Markdown projections are documented one-way exports for their respective ecosystems; round-trip back into CX recovers structure but not the lossy-on-emit metadata. The CXCol / Parquet bridges are lossless for table-shaped data and one-way for everything else.
The hash is taken over strict canonical bytes (see canonical-strict), not over the surface form. Two documents that round-trip identically through a lossless projection hash to the same SHA-256 — including across surfaces. This is the foundation of CX's content-addressing story (see hash).