INTERPRETATION — T-002
SPEC §3.5 is short. These are the four ambiguities the brief flagged
plus how I resolved them. Each interpretation is testable, conservative,
and reversible by a single change in src/profile.ts.
I-1 Tags: "EXT Appendix A" does not exist
Spec text:
No tags except those listed in EXT Appendix A (registered tag set).
Ambiguity: EXT.md does not currently contain an Appendix A. The
"registered tag set" referenced is therefore an empty set, an
unspecified set, or a TODO-set.
Interpretation (conservative): No tags are accepted in v0.2. The
encoder has no tag-emission path; the decoder rejects any major-6 byte
with E_TAG. Adding a tag later is a single change atsrc/profile.ts:PROFILE.ALLOWED_TAGS plus a switch case indecode.ts:decodeValue().
Why conservative is right here: rejecting now, opening up later, is
a forward-compatible move. Accepting unknown tags now and later
discovering they collide with v0.3 semantics would be a wire-incompat
break.
Test coverage: strict-mode.test.ts > "any tag rejected" (CBOR tag
0 / RFC date string).
I-2 Text strings: UTF-8 normalisation
Spec text:
Map keys MUST be text strings in v0.2.
(Plus the implicit "text string" usage throughout.)
Ambiguity: "Text string" doesn't pin a normalisation form. The same
visually-identical string can be encoded as different UTF-8 byte
sequences (NFC vs NFD; precomposed é vs decomposed e + combining
acute). If two encoders pick differently, signatures don't verify.
Interpretation: Follow RFC 8949 §3.1: a CBOR text string is a
sequence of UTF-8 code units, with no normalisation applied. Bytes
round-trip verbatim. If a HERALD application wants Unicode-equivalence
checks, that's an application-layer concern, not a wire-format one.
Why this is right: RFC 8949 itself takes this position, and
agreeing with the underlying RFC means we agree with cbor2,cbor-x, cborg, and every other deterministic CBOR implementation.
Imposing NFC on top of CBOR-deterministic would be a HERALD-specific
break.
Test coverage: round-trip.test.ts > "strings (ASCII and unicode)"
covers café, 日本語, 🦄✨. None of the seven worker-A vectors
contain non-ASCII keys, but the encoder does the same UTF-8 byte work
for all string sizes.
I-3 Big integers: bigint vs tagged bignum
Spec text:
No tags except those listed in EXT Appendix A.
Ambiguity: RFC 8949 §3.4 encodes integers outside [-(2^64), 2^64-1]
using tag 2 (positive bignum) or tag 3 (negative bignum). SPEC §3.5
prohibits tags. So integers outside that range cannot be encoded at all
under the v0.2 profile.
Interpretation: Support only integers in [-(2^64), 2^64-1]. Any
attempt to encode a bigger or smaller bigint throws E_INT_RANGE.
JS-side, the encoder accepts both number and bigint; the decoder
returns number for values within Number.MAX_SAFE_INTEGER andbigint for everything else.
Why this is right: the spec literally forbids the only two tags
that can carry larger integers. No envelope field in v0.2 needs
greater range (timestamps fit in u64 forever; nonces are byte strings,
not integers).
Test coverage: edge-cases.test.ts > "bigint inside u64 range,
round-trips""bigint > 2^64-1 throws", "bigint < -(2^64) throws".
I-4 Float compaction rules
Spec text:
Floats MUST use the shortest representation that round-trips
losslessly; otherwise integers MUST be used.
Ambiguity: "Shortest" is interpreted by RFC 8949 §4.2.2 as "try
float16, then float32, then float64 — the smallest that preserves
value wins". The spec doesn't restate the algorithm; some readers
might interpret "shortest" as "use whichever IEEE-754 width the source
language uses natively".
Interpretation: Follow RFC 8949 §4.2.2 exactly. The encoder tries
float16, then float32, then float64. The decoder rejects any wider
representation when a narrower one would round-trip exactly
(E_FLOAT_WIDE).
The integer-prefer-over-float clause ("otherwise integers MUST be
used") is a separate check applied first: if the JS number is
integer-valued and within safe-integer range, encode as a CBOR
integer regardless of how IEEE-754 would represent it. The decoder
rejects integer-valued floats with E_FLOAT_IS_INT.
Why this is right: RFC 8949 §4.2.2 is the only canonical-encoding
algorithm widely implemented; agreeing with it is what gives us byte-
for-byte interop with cbor2. None of the seven worker-A vectors
contain floats, so this clause is not exercised by interop tests yet —
but T-003 attestations like temperature: 0.7 and confidence: 0.62
will hit it.
Test coverage:
edge-cases.test.ts > "integer-prefer rule: 7.0 encodes as integer 7"> "non-integer float: 0.5 encodes as float16"> "non-integer float that doesn't fit float16 promotes to float32"> "non-integer float that doesn't fit float32 promotes to float64"strict-mode.test.ts > "integer-valued float ...",
"float64 value that fits in float32 ...",
"float32 value that fits in float16 ..."
Other small choices worth flagging
CBOR undefined (Simple 23) is rejected
The profile says nothing about undefined. RFC 8949 §3.3 lists it as a
valid simple value. JS's undefined and CBOR's Simple(23) are the
same shape. I rejected it on both encode and decode because:
- It's not in the SPEC §3.5 enumerated allowed types.
- JS code that passes
undefinedfor a "field absent" usually means
the field shouldn't exist at all (just omit the key).
- Allowing
undefinedwould create two encodings for "field absent"
(omit vs explicit Simple(23)), breaking byte-uniqueness.
If a future HERALD use-case needs explicit absence-of-value, we'd add
it consciously rather than inheriting it.
Object.prototype instances only
encode.ts:isPlainObject() checks the prototype chain. This rejects
class instances, which would otherwise have non-enumerable methods
that Object.keys skips but a future Object.getOwnPropertyNames
would include. By rejecting at the boundary we don't need to defend
against that drift later.
Trailing bytes after top-level value: rejected
The decoder asserts cursor.pos === bytes.length after decoding the
top-level value. A real parser would let trailing bytes through
because they "might" be the start of a second value; a strict CBOR
deserialiser must reject them, because two CBOR items concatenated is
not a single CBOR item. This caught one subtle bug during early
testing where the cursor underran by 1 on float16.