Skip to content

Add a CBOR module that reads and writes TLA+ values - #136

Open
younes-io wants to merge 9 commits into
tlaplus:masterfrom
younes-io:cbor
Open

younes-io wants to merge 9 commits into
tlaplus:masterfrom
younes-io:cbor

Conversation

@younes-io

@younes-io younes-io commented Oct 7, 2026 •

Copy link
Copy Markdown

Why

tlaplus/tlaplus#1467 asks for a trace format that harnesses in Rust, Go, TypeScript, and Python can read without losing TLA+ types. JSON cannot tell a set from a sequence, a model value from a string, or a function from a record. This PR adds a CBOR module (RFC 8949) with ToCBOR and FromCBOR for byte sequences and CBORSerialize and CBORDeserialize for files, implemented as TLC overrides in the style of Json. TLC's -dumpTrace can later be a thin layer on top of it.

What changed

  • modules/CBOR.tla documents the encoding and tlc2.overrides.CBOR implements the four operators.
  • tests/CBORTests.tla pins the bytes of every value kind, and tests/java/tlc2/overrides/CBORGoldenTest.java, a second encoder with no shared code, checks those bytes from ant test.
  • The README lists the module.

The encoding

Integers, booleans, and strings are the CBOR types of the same name. A model value is tag 39 (Identifier) around its name. A set is tag 258 (Mathematical finite set) around an array of its elements. A function whose domain is 1..n is an array, which covers sequences, tuples, and the empty function. A function whose domain is a non-empty set of strings is a map, which covers records. Any other function is tag 33000 around an array of [argument, value] pairs.

Set elements, map keys, and pairs are sorted by their encoded bytes, as RFC 8949 section 4.2.1 specifies, so values that TLC considers equal are written as identical bytes whatever their Java class.

Why a new tag

Tags 39 and 258 were already registered with IANA. Tag 33000 was registered for this module on 2026-10-08. It is needed because a TLA+ function can have integers, model values, tuples, sets, or records as arguments, and a CBOR map with such keys does not survive every decoder. Go's fxamacker/cbor rejects a map whose key is an array, and a JavaScript object has only string keys. An array of pairs decodes in every language. Tag 259 does not help, because it marks a map and leaves the key problem in place.

No registered tag describes a function or map encoded as an array of pairs. The closest are 259 and 275, which tag maps, and 281, which tags Lisp cons cells. 33000 is in the First Come First Served range. A comment below holds the registration template as filed; the registry row is at https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml and the module header links to it.

Scope

  • Every finite TLC value round-trips, provided TLC can compare the elements of each set and function domain in it. The header states the depth limit, 512 nested data items, which both directions enforce.
  • The form of a function follows its domain, so one variable can be an array in one state and a map or tag 33000 in the next. A reader must accept all three. The header says so.
  • Not in this PR: the -dumpTrace cbor option in TLC, and atomic file replacement, which Json does not do either.

Tradeoffs

Deciding the form by the value, not by TLC's Java class, is what makes equal values byte-identical. The cost is the shape change above. A typed reader needs a small custom decoder for functions. The alternative, writing by Java class, would make <<>> and the empty record differ on the wire while TLC says they are equal.

Impact on existing code

A new module. The only change to existing code registers the class in TLCOverrides.java. The build now states Java 11 as its floor, which it already needed: IOUtils uses Java 11 file APIs and the tla2tools.jar it downloads is built for Java 11. ant test runs one JUnit test before TLC.

Verification

  • ant test passes. CBORTests.tla has 121 assumptions, among them 31 byte-exact goldens, round trips of exhaustive small value spaces, and 55 inputs that must be refused with an exact message.
  • CBORGoldenTest agrees with every golden, and Python's cbor2 reads them as sets, lists, dicts, and tags as intended.
  • A counterexample written from a POSTCONDITION decodes with cbor2 and with Go's fxamacker/cbor.
  • Three mutants fail the suite: sorting signed bytes, dropping the encoder depth check, and letting a nested length claim bytes an enclosing array still needs.

ToCBOR returns the CBOR (RFC 8949) encoding of any finite TLA+ value as a
sequence of integers in 0..255, and CBORSerialize writes the same bytes to
a file, so harnesses in Go, Rust, TypeScript and Python can read TLC values
without the type loss of JSON. Sets, model values and functions with
non-string domains keep their TLA+ meaning.

Equal values encode as identical bytes. The encoder decides how a function
is written from its domain, not from its Java class, and sorts set elements
and keys by their encoded bytes instead of by TLC's intern order. It never
normalizes the values it reads.

CBORTests writes each golden inline as a sequence of hex literals and
requires ToCBOR to return exactly those bytes, for every row of the
encoding table and for groups of TLC-equal values held in different Java
classes. tests/CBORTests/fixtures.py is a second encoder that shares no
code with CBOR.java; run by hand, it checks every golden sequence in
CBORTests.tla against its own output.

FromCBOR and CBORDeserialize keep their fallback bodies until the decoder
lands.

tlaplus/tlaplus#1467

[Feature]

Signed-off-by: younes-io <git@younes.io>
FromCBOR decodes one CBOR data item from a sequence of integers in 0..255,
and CBORDeserialize from a file, so a spec can load a trace or state that a
harness wrote. Both use one decoder. It decides the TLC class of a map or a
pair list with the same shape rule the encoder uses to pick the form, and
builds values already normalized in TLC's order.

The decoder is lenient on form and strict on meaning. It accepts set
elements, keys and pairs in any order, integers and lengths in any width,
and maps with keys that are not strings, because Python, Go and Rust
encoders differ on exactly these by default. It rejects duplicates under
TLC equality, values TLC cannot represent or compare, and nesting deeper
than 512 data items, and names the operator, the file if there is one, and
the byte offset. It looks model values up among the model's and never
creates one, because TLC reads model values back from its disk queues by
index.

The tests read every golden back, read inline inputs that TLC never writes
and require them to encode again in canonical form, round-trip exhaustive
small value spaces, and check that a file CBORSerialize wrote reads back
as FromCBOR(ToCBOR(v)).

tlaplus/tlaplus#1467

[Feature]

Signed-off-by: younes-io <git@younes.io>
CBORTests matches the exact message of every input FromCBOR rejects with
AssertError, each input written inline as a sequence of hex literals, so
every rejection is shown to be an EvalException that names the byte
offset rather than a 2154 stack trace. The cases cover truncation, hostile
lengths, trailing bytes, types and tags without a TLA+ meaning, integers
outside TLC's range, invalid UTF-8, duplicates and incomparable values
under TLC equality, unknown model values, and nesting deeper than 512.

The tests also cover arguments that are not byte sequences or strings, a
missing file, a decoding error that names the file, and values ToCBOR and
CBORSerialize cannot encode, and show that a refused value leaves the
existing file as it was.

tlaplus/tlaplus#1467

[Tests]

Signed-off-by: younes-io <git@younes.io>
The row sits between Bitwise and Combinatorics, where the table's
case-insensitive alphabetical order puts it, and links the Java
override like the other rows.

tlaplus/tlaplus#1467

[Doc]

Signed-off-by: younes-io <git@younes.io>
ToCBOR wrote values that FromCBOR refused for their depth. The encoder
now counts data items as the decoder does and refuses a value nested
more than 512 deep, so an integer can lie inside at most 511 sequences
or records, 255 sets, or 170 functions that use tag 33000. The round
trip law in CBOR.tla now excludes the values it never held for: those
with a set or a function domain whose elements TLC cannot compare, such
as {1, "a"}.

The decoder checked each length against the bytes that remain, but each
of 512 nested arrays could claim those bytes again, and a 4 MB file
exhausted a 4 GB heap. A length may now claim only the bytes that the
enclosing arrays and maps do not still need.

The header of CBOR.tla is the format's specification for producers and
readers in other languages, and several statements in it were wrong.
Its two ordering examples held under shortest-first order as well; the
example is now 10, 100, -1. CBOR libraries do not sort the pairs of tag
33000. Go's fxamacker/cbor rejects only map keys that are arrays, not
integers or tagged items. The header now also says that tag 33000 is
not registered with IANA yet, and that a function is an array, a map,
or tag 33000 depending on its domain alone, so a reader must accept all
three forms for the same variable.

No golden compared encodings that first differ at a byte of 0x80 or
more, so an encoder that sorted signed bytes passed every test. Four
goldens now do: two sets of integers and model values, a set of
non-ASCII strings, and a function whose domain mixes a model value and
an integer. CI runs tests/CBORTests/fixtures.py on Ubuntu, so the
golden bytes are checked against the second encoder on every build.

tlaplus/tlaplus#1467

[Bug]

Signed-off-by: younes-io <git@younes.io>
@younes-io
younes-io marked this pull request as draft October 7, 2026 03:46
@younes-io

younes-io commented Oct 7, 2026 •

Copy link
Copy Markdown
Author

Proposed IANA registration for tag 33000

Tags from 32768 up are First Come First Served. IANA asks for the four-line template from RFC 8949 section 9.2 plus a URL that describes the semantics. Before I file it, here is what I would send, so that the number, the data item, and the wording can be agreed in this PR. The description text lives in a gist, https://gist.github.com/younes-io/8bbc63cffd4811f6aa2776c7301eb360, which I will keep in step with this thread; its current text is also below. The module header gets the registry link when the row appears.

Update 2026-10-08. The request went to IANA with the template below and both contacts.

Update 2026-10-09. IANA registered tag 33000 on 2026-10-08, with the template below as filed: https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml (row 33000, template https://www.iana.org/assignments/cbor-tags/template/33000). The module header and CBOR.java now point at the registry.

Template

Tag: 33000
Data Item: array
Semantics: TLA+ function as an array of [argument, value] pairs
Point of Contact: Younes Akhouayri, Markus Alexander Kuppe
Description of Semantics: https://gist.github.com/younes-io/8bbc63cffd4811f6aa2776c7301eb360

Description of semantics

A function in TLA+ is a finite table from arguments to values. Its arguments can be integers, strings, booleans, model values, tuples, sets, records, or other functions. CBOR maps allow such keys, but the native dictionaries of several host languages do not. Go cannot use an array as a map key, and a JavaScript object turns every key into a string. Tag 33000 therefore writes a function as an array of pairs, which every language reads.

The tagged data item is an array. Each element is an array of exactly two data items, the argument first and then the value at that argument.

  • No two arguments are equal. A reader rejects a tagged item with a repeated argument.
  • The pairs may appear in any order. A producer that wants a deterministic encoding sorts the pairs by the encoded bytes of their arguments, as RFC 8949 section 4.2.1 sorts map keys. The reference implementation writes that order and reads any.
  • Arguments and values are any CBOR data items, including nested tag 33000 items.
  • An empty array denotes the empty function. The reference implementation writes the empty function as a plain empty array (0x80) without this tag, and a reader accepts both.

The producer of this tag, the CBOR module of the TLA+ CommunityModules, writes a function as a plain CBOR array when its domain is 1..n (a sequence) and as a CBOR map when its domain is a non-empty set of text strings (a record). Tag 33000 covers every other domain. Related registered tags used by the same module are 39 (identifier, for model values) and 258 (mathematical finite set).

Examples. The function 0 :> "x" @@ 1 :> "y":

d9 80e8        tag 33000
   82          array of 2 pairs
      82 00 61 78    [0, "x"]
      82 01 61 79    [1, "y"]

The function on the tuples <<1, 2>> and <<2, 1>> that maps both to 3:

d9 80e8 82
   82 82 01 02 03    [[1, 2], 3]
   82 82 02 01 03    [[2, 1], 3]

Open points for reviewers

  • The number. Settled: IANA assigned 33000 on 2026-10-08. Nothing else in the registry describes a pair list as a function or map.
  • Pairs versus a map. Tag 259 marks a CBOR map with arbitrary keys. It would avoid a new registration, but the map underneath still fails in Go when a key is an array, and loses integer keys in JavaScript objects. That is why the module writes pairs.
  • Point of contact. Settled: @lemmy agreed below, and both names are on the request.

@lemmy

lemmy commented Oct 8, 2026

Copy link
Copy Markdown
Member

First reaction: Could the Python testing be adapted and merged into the current Java test suite?

This comment was marked as resolved.

@lemmy

lemmy commented Oct 8, 2026

Copy link
Copy Markdown
Member
  • Point of contact. It would be good to have a TLA+ Foundation maintainer on the registration as well, so that the contact outlives any single contributor. @lemmy, would you like to be listed alongside me? If so, tell me the name and email to use.

Yes (shared my email privately).

build.xml compiled the overrides with source and target 1.8, but
IOUtils already calls Files.readString and Files.writeString, which
Java 11 added, and the tla2tools.jar that the build downloads is class
version 55 and needs Java 11 to run. The build has needed Java 11 since
2021, when IOUtils adopted those calls.

The javac task now passes release 11 instead. With release, javac
checks every call against the Java 11 class library rather than the
library of the JDK that runs the build, so a call to a newer API fails
to compile instead of surfacing in review.

[Build]

Signed-off-by: younes-io <git@younes.io>
tests/CBORTests/fixtures.py checked the golden bytes in CBORTests.tla
against a second CBOR encoder, but only CI ran it, only on Ubuntu, and
only as a separate workflow step. CBORGoldenTest is a JUnit 4 port of
it: the same encoder, the same table of golden values under the same
names, and the same checks. Every stated golden must match the bytes
this encoder writes, every name must have a value, and every value must
be stated by some ASSUME. The test fails once and lists every problem,
each with its line in CBORTests.tla and, for a mismatch, the expected
bytes as a TLA+ sequence ready to paste. The optional cbor2 printout is
gone.

The test uses JUnit alone, not tla2tools.jar or CBOR.java, so the two
encoders still share no code. The compile target builds tests/java,
and the test target runs the test with JUnitCore before TLC, so a
golden mismatch fails the build in seconds on every operating system.
JUnitCore runs through the plain java task, because Ant's junit task
needs ant-junit on the runner. The CI step that ran the Python script
is removed, which leaves the workflows as they are upstream.

[Tests][Build]

Signed-off-by: younes-io <git@younes.io>
@younes-io
younes-io requested a review from lemmy October 9, 2026 03:34
The module header and the TAG_FUNCTION comment said the tag was not
registered yet. IANA assigned it on 2026-10-08, with the semantics
the header describes:
https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml

[Doc]

Signed-off-by: younes-io <git@younes.io>
@younes-io

Copy link
Copy Markdown
Author

IANA registered tag 33000 today. The row is live at https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml, with the semantics as proposed above and both of us as contacts. Thanks, @lemmy.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
* decoder rejects for its depth. A tag is a data item: a set takes two levels and the pairs of tag 33000
* three.
*/
private static final int MAX_DEPTH = 512;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A StackOverflowError is a perfectly acceptable failure mode, so there is no need to impose an arbitrary recursion-depth limit. Users can adjust the JVM's thread stack size using -Xss (e.g., -Xss4m) if needed.

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the fixed depth limit and the depth tracking from both the encoder and decoder. Deep values can now reach the JVM stack limit. The docs mention StackOverflowError and adjusting -Xss.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
/** A non-empty set of strings: a map keyed by text. */
RECORD,
/** Anything else: tag 33000 around [x, f[x]] pairs. */
PAIRS

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using established TLA+ terminology, such as sequences/tuples, records, and functions. It's not immediately clear to me what “Pair” refers to or how it relates to these existing concepts.

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the Shape enum, including PAIRS. The code now uses TLC's sequence, record, and function conversions. The remaining mentions of pairs describe the [argument, value] entries in the CBOR encoding, not a separate TLA+ type.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
PAIRS
}

private static Shape shape(final Value[] domain) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using TLC's conversion methods on Value. Each returns null when the value isn't of that kind:

  • v.toTuple() != null means v is a sequence. The domain is exactly 1..n, including the empty case.
  • v.toRcd() != null means v is a record. The domain is a finite set of strings, including the empty case.
  • v.toFcnRcd() != null means v is a function.

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the custom shape checks with toFcnRcd(), toTuple(), and toRcd(). Tuple conversion comes first so empty functions keep their existing array encoding.

I only try toRcd() when all domain elements are strings. It normalizes before checking their types, which can fail on mixed domains that the encoder otherwise accepts. The finite-domain check also stays before expanding a function.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
/**
* Writes the one encoding of a TLC value. Values that TLC considers equal produce identical bytes,
* whatever their Java class and whatever order TLC interned their strings in, so the encoder sorts by
* encoded bytes and never consults TLC's normalized order. It never calls normalize() either, so it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest calling normalize() before writing, as Json does. TLC normalizes values in place everywhere, so avoiding it here doesn't buy much, and it adds subtle code: dodging toFcnRcd(), and removing duplicates from set elements by comparing their bytes. Once a set is normalized, its elements are already in a fixed order with no duplicates. Only record fields still need sorting by their encoded bytes, which RFC 8949, section 4.2.1 requires for CBOR maps.

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the special handling that avoided toFcnRcd() and the claim that encoding never changes TLC values in place.

I kept byte sorting because TLC's order differs from this module's existing encoding order. For example, TLC orders the values as -1, 10, 100, while their encoded bytes sort as 10, 100, -1. Using TLC's order for sets and function arguments would change the output.

I also kept byte-based duplicate removal for sets. Normalizing every set would reject inputs such as {1, "a"}, which ToCBOR currently accepts. FromCBOR still rejects that mixed set, as documented.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
/**
* Reads one CBOR data item into a TLC value. Liberal about form, strict about meaning: it accepts set
* elements, map keys and pairs in any order, integers and lengths in any width, and maps with keys that
* are not strings. Encoders in other languages differ on exactly these by default, and none of them

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph should would benefit from using TLA+ terminology. Also, what other languages and why do they matter here?

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote this using the TLA+ types the decoder returns. The paragraph now states which input forms it accepts and rejects, without the vague reference to other languages.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
* changes the TLA+ value. It rejects every item whose TLA+ meaning is missing, out of TLC's range, or
* ambiguous, and names the source and the byte offset of the item.
*
* <p>It never creates a model value. ModelValue.make at run time leaves ModelValue.mvs stale, and TLC

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should either be moved or clarified to state that Decoder handles MVs. It appears to look up and reuse existing MVs, failing if none exists. This is reasonable.

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified this. The decoder handles model values by looking up and reusing existing ones. An unknown name causes an error. Decoding never creates a new model value.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
case 25:
case 26:
case 27:
throw error(start, "a floating-point number has no TLA+ counterpart");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLA+ does have Reals. The real limitation is TLC's, not the language's: TLC has no value class for non-integer reals.

"TLC cannot represent a floating-point number"

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the message to "TLC cannot represent a floating-point number" and updated the test. The error now describes TLC's limitation, not a limitation of TLA+.

Comment thread modules/tlc2/overrides/CBOR.java Outdated
case 31:
throw notWellFormed(start);
default:
throw error(start, "a simple value has no TLA+ counterpart");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"a CBOR simple value has..."?

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to "a CBOR simple value has no TLA+ counterpart" and updated the test.

Comment thread modules/CBOR.tla
(* string that is not valid Unicode, a value nested too deep), TLC reports *)
(* an error. *)
(***************************************************************************)
ToCBOR(value) ==

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should ToCBOR and FromCBOR be declared LOCAL? Apart from tests, under what circumstances would a spec need to call them directly?

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've kept them public for specs that work with bytes without using files. For example, Len(ToCBOR(message)) can check an encoded message-size limit. FromCBOR(bytes) can interpret a byte sequence already available to the spec.

I don't have an existing non-test consumer to point to yet. These are the intended uses, and I've added examples to the documentation.

Comment thread modules/CBOR.tla
@@ -0,0 +1,112 @@
-------------------------------- MODULE CBOR --------------------------------
(***************************************************************************)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This header comment seems overly technical and focused on implementation details. Consider moving it elsewhere or replacing it with a high-level description aimed at users of the CBOR module, who presumably don't need to know the internals of TLA+ serialization and deserialization.

@younes-io younes-io Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shortened the header to explain how to use the operators, with examples and the main limitations. Moved the encoding rules and interoperability details to docs/CBOR.md, with references from the module and README.

Use TLC conversions for function representations while preserving encoded-byte ordering and supported mixed inputs.

Remove the fixed nesting limit, clarify decoder errors and model-value handling, and add regression coverage.

Keep the in-memory operators public. Shorten the module header and move the encoding contract to docs/CBOR.md.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants