<?xml version="1.0" encoding="utf-8"?>
<rfc version="3" ipr="trust200902"
     docName="draft-bustamante-pson-00"
     submissionType="IETF" category="std"
     xml:lang="en"
     xmlns:xi="http://www.w3.org/2001/XInclude"
     indexInclude="true">

<front>
<title abbrev="PSON">
Packed Sensor Object Notation (PSON)
</title>
<seriesInfo value="draft-bustamante-pson-00"
            stream="IETF" status="standard"
            name="Internet-Draft"/>

<author initials="A."
        surname="Luis Bustamante"
        fullname="Alvaro Luis Bustamante">
  <organization>Internet of Thinger SL</organization>
  <address>
    <postal><country>Spain</country></postal>
    <email>alvaro@thinger.io</email>
  </address>
</author>

<date year="2026" month="March" day="30"/>

<area>ART</area>
<workgroup>Internet Engineering Task Force</workgroup>

<keyword>IoT</keyword>
<keyword>binary</keyword>
<keyword>encoding</keyword>
<keyword>serialization</keyword>
<keyword>constrained</keyword>

<abstract>
<t>PSON (Packed Sensor Object Notation) is a compact binary
data encoding format designed for IoT environments where
bandwidth, memory, and processing power are constrained.
PSON efficiently represents the data types commonly found
in sensor telemetry and device interaction: integers,
floating-point numbers, booleans, strings, binary blobs,
arrays, and key-value maps. It achieves significant size
reductions over JSON (40-75% for typical IoT payloads)
through inline small value encoding and variable-length
integers. PSON is self-describing (no external schema
required for decoding) and designed for minimal
implementation complexity on microcontrollers.</t>
</abstract>

</front>

<middle>

<section anchor="introduction"><name>Introduction</name>

<section anchor="purpose"><name>Purpose</name>
<t>PSON is a binary serialization format that provides a
compact, self-describing encoding for structured data. It
is designed as a drop-in binary replacement for JSON in
environments where bandwidth and processing resources are
limited -- particularly IoT devices, embedded systems, and
constrained networks.</t>
<t>PSON has been deployed in production IoT systems since 2015
as the native encoding format of the Thinger.io platform,
processing sensor data across thousands of connected
devices. This specification formalizes the encoding that
has been validated through a decade of production use.</t>
</section>

<section anchor="background"><name>Background</name>
<t>IoT devices frequently exchange structured data: sensor
readings, configuration parameters, device metadata, and
command payloads. JSON <xref target="RFC8259"/> is the de
facto standard for structured data interchange but imposes
significant overhead on constrained devices:</t>
<ul spacing="compact">
<li><strong>Verbose encoding:</strong> Field names and
values are repeated as text in every message.</li>
<li><strong>Parsing cost:</strong> Text parsing requires
string-to-number conversion and delimiter scanning.</li>
<li><strong>Size inefficiency:</strong> Common IoT values
like small integers, booleans, and short strings are
disproportionately expensive in JSON.</li>
</ul>
<t>PSON addresses these issues through a binary encoding that
preserves JSON's self-describing nature while dramatically
reducing wire size and parsing complexity.</t>
</section>

<section anchor="relationship-to-cbor">
<name>Relationship to CBOR</name>
<t>PSON shares structural similarities with CBOR
<xref target="RFC8949"/>. Both use a tag byte with 3
bits for type identification and 5 bits for an inline
value. However, PSON and CBOR make different design
trade-offs reflecting different goals: CBOR is a
general-purpose binary encoding designed for broad
applicability; PSON is a minimal encoding designed
specifically for integration with the IOTMP protocol on
constrained IoT devices.</t>

<t><strong>Protocol-Encoding Integration.</strong> PSON's
primary design motivation is architectural. PSON and the
IOTMP protocol share identical encoding primitives: the
same tag-byte structure (3-bit type + 5-bit inline value)
and the same varint encoding for variable-length integers.
A single encoder/decoder implementation on a constrained
device serves both the protocol framing layer and the
application data layer. Adopting CBOR would require
maintaining two independent type systems -- CBOR's major
types alongside IOTMP's own field encoding -- increasing
code size and complexity for no functional benefit on
these devices.</t>

<t><strong>Reduced Scope.</strong> CBOR is designed to cover
a wide range of use cases through extensibility mechanisms
that add implementation complexity:</t>

<ul>
<li><strong>Semantic tags</strong> (CBOR major type 6):
Over 350 IANA-registered tags for dates, bignums, decimal
fractions, URIs, regular expressions, and more. Each tag
number can be 1-9 bytes. Decoders must handle unknown tags
gracefully. PSON has no semantic tag system -- the 8 wire
types cover all data types needed for IoT
applications.</li>
<li><strong>Indefinite-length encoding</strong>: CBOR
allows arrays, maps, and strings of unknown length at
encode time, terminated by a break stop code (0xFF). This
roughly doubles the decoder's state machine complexity.
PSON requires definite lengths, which is sufficient for
IoT data that is typically fully known in memory before
encoding.</li>
<li><strong>Half-precision floats</strong> (IEEE 754
binary16): CBOR supports 16-bit, 32-bit, and 64-bit
floats, with a preferred serialization rule that requires
testing each value against all three sizes. Most platforms
lack native float16 support, requiring dedicated
conversion routines. PSON supports only 32-bit and 64-bit
floats -- the two sizes natively available on all target
platforms.</li>
<li><strong>Simple values</strong>: CBOR defines a
256-value extensible space for simple values (currently:
false, true, null, undefined, plus unassigned slots). PSON
defines exactly three discrete values: false, true, and
null.</li>
<li><strong>Arbitrary map key types</strong>: CBOR allows
any data type as a map key (integers, arrays, nested
maps). PSON restricts map keys to strings, matching JSON's
object model and simplifying decoder
implementation.</li>
</ul>

<t><strong>Wire Size.</strong> In terms of encoded size,
PSON and CBOR produce comparable results for typical IoT
data. PSON encodes unsigned integers 0-30 in a single byte
(vs. CBOR's 0-23), providing a modest advantage for values
24-30. For most payloads, the size difference between PSON
and CBOR is negligible. The justification for PSON is not
superior compression -- it is the reduced implementation
complexity and the shared encoding primitives with
IOTMP.</t>

<t><strong>Byte Ordering.</strong> CBOR encodes all
multi-byte values in big-endian (network byte order). PSON
uses little-endian for floating-point values, matching the
native byte order of virtually all IoT microcontrollers
(ARM Cortex-M, ESP32, RISC-V). See
<xref target="byte-ordering"/>.</t>
</section>

<section anchor="relationship-to-messagepack">
<name>Relationship to MessagePack</name>
<t>MessagePack <xref target="MessagePack"/> is another binary
encoding with similar goals. Like CBOR, it uses big-endian
byte ordering and a more complex type system (with multiple
fixed-width integer sizes and format families). PSON's
simpler tag-byte design and varint-based overflow mechanism
result in a smaller and more uniform implementation.</t>
</section>

</section>

<section anchor="terminology"><name>Terminology</name>
<t>The key words "MUST", "MUST NOT", "REQUIRED", "SHALL",
"SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED",
"NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document
are to be interpreted as described in BCP 14
<xref target="RFC2119"/> <xref target="RFC8174"/> when,
and only when, they appear in all capitals, as shown
here.</t>
</section>

<section anchor="design-goals"><name>Design Goals</name>
<ul spacing="compact">
<li><strong>Compactness:</strong> Minimize encoded size for
typical IoT data (sensor readings, configuration,
commands).</li>
<li><strong>Self-describing:</strong> No external schema
required for decoding. The wire type and structure are
embedded in the data.</li>
<li><strong>Simplicity:</strong> Minimal implementation
complexity. Encodable/decodable in a single pass with no
lookahead.</li>
<li><strong>JSON superset:</strong> PSON represents all
JSON data types (objects, arrays, strings, numbers,
booleans, null) plus native binary data (wire type 5),
enabling lossless round-trip conversion from JSON to
PSON. Binary data has no JSON equivalent and requires
application-level encoding (e.g., Base64) when
converting to JSON.</li>
<li><strong>Protocol integration:</strong> Share encoding
primitives (tag-byte structure, varint encoding) with
IOTMP, enabling a single encoder/decoder to serve both
protocol framing and application data.</li>
</ul>
</section>

<section anchor="tag-byte-structure">
<name>Tag Byte Structure</name>
<t>Every PSON value begins with a single tag byte that
encodes both the data type and, for small values, the
value itself:</t>
<artwork><![CDATA[
+-------------+--------------+
|  Wire Type  | Inline Value |
|  (bits 7-5) | (bits 4-0)   |
+-------------+--------------+
]]></artwork>
<ul spacing="compact">
<li><strong>Wire Type</strong> (3 most significant bits):
Identifies the data type (0-7).</li>
<li><strong>Inline Value</strong> (5 least significant
bits): For values 0-30, the value is stored directly in
the tag byte, requiring no additional bytes. If the inline
value is 31 (0x1F mask), the actual value follows the tag
byte as a varint.</li>
</ul>
<t>The tag byte formula is:
<tt>tag = (wire_type &lt;&lt; 5) | inline_value</tt>.</t>
<t>This design means that common small values (integers
0-30, short strings, small maps and arrays) are encoded
in the minimum possible space.</t>
</section>

<section anchor="wire-types"><name>Wire Types</name>
<table>
<thead>
<tr>
<th>Value</th>
<th>Name</th>
<th>Tag Range</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>unsigned_t</td>
<td>0x00-0x1F</td>
<td>Unsigned integer. Inline 0-30 or varint.</td>
</tr>
<tr>
<td>1</td>
<td>signed_t</td>
<td>0x20-0x3F</td>
<td>Negative integer. Stored as absolute value.</td>
</tr>
<tr>
<td>2</td>
<td>floating_t</td>
<td>0x40-0x5F</td>
<td>IEEE 754 float (inline=0) or double (inline=1).</td>
</tr>
<tr>
<td>3</td>
<td>discrete_t</td>
<td>0x60-0x7F</td>
<td>Boolean or null. 0=false, 1=true, 2=null.</td>
</tr>
<tr>
<td>4</td>
<td>string_t</td>
<td>0x80-0x9F</td>
<td>UTF-8 string. Inline or varint = byte length.</td>
</tr>
<tr>
<td>5</td>
<td>bytes_t</td>
<td>0xA0-0xBF</td>
<td>Raw binary data. Inline or varint = byte length.</td>
</tr>
<tr>
<td>6</td>
<td>map_t</td>
<td>0xC0-0xDF</td>
<td>Key-value map. Inline or varint = entry count.</td>
</tr>
<tr>
<td>7</td>
<td>array_t</td>
<td>0xE0-0xFF</td>
<td>Ordered array. Inline or varint = element count.</td>
</tr>
</tbody>
</table>
<t>Wire types 0-7 are defined by this specification. No
additional wire types can be defined (the 3-bit field is
fully allocated).</t>
</section>

<section anchor="encoding-rules"><name>Encoding Rules</name>

<section anchor="unsigned-integers">
<name>Unsigned Integers (Wire Type 0)</name>
<t>Unsigned integers represent non-negative whole
numbers.</t>
<ul spacing="compact">
<li><strong>Values 0-30:</strong> Encoded in a single byte:
<tt>(0 &lt;&lt; 5) | value</tt>.</li>
<li><strong>Values &gt;= 31:</strong> Tag byte is 0x1F,
followed by the value encoded as a varint
<xref target="varint-encoding"/>.</li>
</ul>
<t>Examples:</t>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr><td>0</td><td>00</td><td>1 byte</td></tr>
<tr><td>5</td><td>05</td><td>1 byte</td></tr>
<tr><td>30</td><td>1E</td><td>1 byte</td></tr>
<tr><td>31</td><td>1F 1F</td><td>2 bytes</td></tr>
<tr><td>127</td><td>1F 7F</td><td>2 bytes</td></tr>
<tr><td>300</td><td>1F AC 02</td><td>3 bytes</td></tr>
</tbody>
</table>
</section>

<section anchor="signed-integers">
<name>Signed Integers (Wire Type 1)</name>
<t>Negative integers are encoded using wire type 1 with
the absolute value. Decoding: negate the stored value.</t>
<ul spacing="compact">
<li><strong>Values -1 to -30:</strong> Single byte:
<tt>(1 &lt;&lt; 5) | abs(value)</tt>.</li>
<li><strong>Values &lt;= -31:</strong> Tag byte 0x3F,
followed by the absolute value as a varint.</li>
</ul>
<t>Zero and positive integers MUST use wire type 0
(unsigned_t). Wire type 1 is exclusively for negative
values. Encoding zero as signed_t (tag byte 0x20) is
invalid and a decoder MUST treat it as an error.</t>
<t>Examples:</t>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr><td>-1</td><td>21</td><td>1 byte</td></tr>
<tr><td>-15</td><td>2F</td><td>1 byte</td></tr>
<tr><td>-30</td><td>3E</td><td>1 byte</td></tr>
<tr><td>-31</td><td>3F 1F</td><td>2 bytes</td></tr>
<tr><td>-300</td><td>3F AC 02</td><td>3 bytes</td></tr>
</tbody>
</table>
</section>

<section anchor="floating-point">
<name>Floating-Point Numbers (Wire Type 2)</name>
<t>IEEE 754 <xref target="IEEE754"/> floating-point values.
The inline value selects the precision:</t>
<table>
<thead>
<tr>
<th>Inline Value</th>
<th>Precision</th>
<th>Bytes Following Tag</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>32-bit float (IEEE 754 binary32)</td>
<td>4 bytes, little-endian</td>
</tr>
<tr>
<td>1</td>
<td>64-bit double (IEEE 754 binary64)</td>
<td>8 bytes, little-endian</td>
</tr>
</tbody>
</table>
<t>Inline values 2-30 are reserved for future use. Inline
value 31 (varint extension) is not used for this wire
type. A decoder that encounters inline value 31 or any
reserved inline value (2-30) for this wire type MUST
treat it as a decode error.</t>
<t><strong>Negative zero:</strong> IEEE 754 defines
negative zero (-0.0) as distinct from positive zero
(+0.0). Encoders MUST encode -0.0 as a floating-point
value (wire type 2), not as unsigned integer zero (wire
type 0). Decoders MUST preserve the sign of zero when
converting from PSON to IEEE 754.</t>
<t><strong>NaN and Infinity:</strong> Encoders MUST encode
NaN and Infinity values as 32-bit or 64-bit floats (wire
type 2). These values MUST NOT be promoted to integers.
Encoders SHOULD use a canonical NaN representation (quiet
NaN with all-zero payload) when the specific NaN payload
is not significant.</t>
<t>Examples:</t>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>3.14</td>
<td>40 C3 F5 48 40</td>
<td>32-bit float</td>
</tr>
<tr>
<td>3.141592653</td>
<td>41 38 E9 2F 54 FB 21 09 40</td>
<td>64-bit double</td>
</tr>
</tbody>
</table>
</section>

<section anchor="discrete-values">
<name>Discrete Values (Wire Type 3)</name>
<t>Boolean and null values.</t>
<table>
<thead>
<tr>
<th>Inline Value</th>
<th>Meaning</th>
<th>Encoded (hex)</th>
</tr>
</thead>
<tbody>
<tr><td>0</td><td>false</td><td>60</td></tr>
<tr><td>1</td><td>true</td><td>61</td></tr>
<tr><td>2</td><td>null</td><td>62</td></tr>
</tbody>
</table>
<t>Inline values 3-30 are reserved for future use. Inline
value 31 (varint extension) is not used for this wire
type. A decoder that encounters inline value 31 or any
reserved inline value (3-30) for this wire type MUST
treat it as a decode error.</t>
</section>

<section anchor="strings">
<name>Strings (Wire Type 4)</name>
<t>UTF-8 encoded text strings. The inline value (or
subsequent varint if inline = 31) indicates the byte
length of the string. The string bytes follow immediately,
with no null terminator.</t>
<ul spacing="compact">
<li><strong>Strings of 0-30 bytes:</strong> Single tag byte
with inline length, followed by the string bytes.</li>
<li><strong>Strings of &gt;= 31 bytes:</strong> Tag byte
0x9F, followed by a varint length, followed by the string
bytes.</li>
</ul>
<t>Implementations MUST encode strings as valid UTF-8. A
decoder that encounters invalid UTF-8 sequences SHOULD
treat it as a decode error.</t>
<t>Examples:</t>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td>"" (empty)</td>
<td>80</td>
<td>1 byte</td>
</tr>
<tr>
<td>"hi"</td>
<td>82 68 69</td>
<td>3 bytes</td>
</tr>
<tr>
<td>"temperature"</td>
<td>8B 74 65 6D 70 65 72 61 74 75 72 65</td>
<td>12 bytes</td>
</tr>
</tbody>
</table>
</section>

<section anchor="binary-data">
<name>Binary Data (Wire Type 5)</name>
<t>Raw binary (opaque byte sequences). Encoding is
identical to strings: the inline value (or varint)
indicates byte length, followed by the raw bytes.</t>
<ul spacing="compact">
<li><strong>0-30 bytes:</strong> Single tag byte with
inline length, followed by data.</li>
<li><strong>&gt;= 31 bytes:</strong> Tag byte 0xBF,
followed by a varint length, followed by data.</li>
</ul>
<t>Unlike strings, binary data has no encoding requirement
(no UTF-8 constraint).</t>
</section>

<section anchor="maps-objects">
<name>Maps / Objects (Wire Type 6)</name>
<t>Key-value maps (equivalent to JSON objects). The inline
value (or varint) indicates the number of key-value
pairs.</t>
<t>Each entry consists of:</t>
<ol spacing="compact">
<li>A PSON string (the key). Map keys MUST be
strings.</li>
<li>A PSON value (any wire type, including nested maps
and arrays).</li>
</ol>
<ul spacing="compact">
<li><strong>0-30 entries:</strong> Single tag byte with
inline count.</li>
<li><strong>&gt;= 31 entries:</strong> Tag byte 0xDF,
followed by a varint count.</li>
</ul>
<t><strong>Key ordering:</strong> PSON does not define a
canonical key ordering. However, implementations SHOULD
preserve insertion order to support use cases where
iteration order is significant (e.g., compact streaming
mode in IOTMP).</t>
<t><strong>Duplicate keys:</strong> A PSON map SHOULD NOT
contain duplicate keys. A decoder that encounters duplicate
keys SHOULD reject the map or use the last value for the
duplicated key. The behavior for duplicate keys is
undefined and implementations MUST NOT rely on it.</t>
<t>Example -- {"temp": 25, "hum": 60}:</t>
<artwork><![CDATA[
C2                  # map_t, 2 entries
  84 74 65 6D 70    # string "temp" (key, 4 bytes)
  19                # unsigned 25 (value)
  83 68 75 6D       # string "hum" (key, 3 bytes)
  1F 3C             # unsigned 60 (varint)
]]></artwork>
<t>Total: 13 bytes. Equivalent JSON {"temp":25,"hum":60}
= 20 bytes (35% savings).</t>
</section>

<section anchor="arrays">
<name>Arrays (Wire Type 7)</name>
<t>Ordered sequences of values (equivalent to JSON
arrays). The inline value (or varint) indicates the number
of elements.</t>
<t>Each element is a PSON-encoded value (any wire
type).</t>
<ul spacing="compact">
<li><strong>0-30 elements:</strong> Single tag byte with
inline count.</li>
<li><strong>&gt;= 31 elements:</strong> Tag byte 0xFF,
followed by a varint count.</li>
</ul>
<t>Example -- [1, 2, 3]:</t>
<artwork><![CDATA[
E3                  # array_t, 3 elements
  01                # unsigned 1
  02                # unsigned 2
  03                # unsigned 3
]]></artwork>
<t>Total: 4 bytes. Equivalent JSON [1,2,3] = 7 bytes
(43% savings).</t>
</section>

</section>

<section anchor="byte-ordering">
<name>Byte Ordering</name>
<t>All multi-byte floating-point values (float32, float64)
MUST be encoded in little-endian byte order (least
significant byte first).</t>
<t><strong>Rationale:</strong> Traditional Internet
protocols use big-endian ("network byte order"), a
convention established when dominant networking hardware
was big-endian. Today, virtually all microcontrollers and
processors used in IoT are little-endian: ARM Cortex-M,
ESP32 (Xtensa), RISC-V, and x86. Little-endian encoding
allows constrained devices to write float and double values
directly from memory to the wire with no conversion. On
the most constrained ARM cores (Cortex-M0/M0+), which
lack a hardware byte-reversal instruction (REV), byte
swapping a 32-bit float requires 4 instructions per
value.</t>
<t>Integer values use varint encoding
<xref target="varint-encoding"/>, which is byte-order
independent by design.</t>
<t>This is the same design choice made by Protocol Buffers
<xref target="ProtocolBuffers"/>, which encodes
fixed-width floats and doubles in little-endian regardless
of platform.</t>
</section>

<section anchor="varint-encoding">
<name>Varint Encoding</name>
<t>PSON uses Protocol Buffers-style variable-length integer
encoding for values that exceed the inline capacity
(&gt;= 31).</t>

<section anchor="varint-algorithm">
<name>Encoding Algorithm</name>
<ul spacing="compact">
<li>Each byte uses bits [6:0] for data (7 bits per
byte).</li>
<li>Bit [7] is a continuation flag: set (1) if more bytes
follow, clear (0) for the last byte.</li>
<li>Least significant group comes first (little-endian
byte order).</li>
</ul>
</section>

<section anchor="varint-examples">
<name>Examples</name>
<table>
<thead>
<tr>
<th>Decimal</th>
<th>Hex</th>
<th>Varint Bytes (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td><td>0x00</td><td>00</td><td>1 byte</td>
</tr>
<tr>
<td>1</td><td>0x01</td><td>01</td><td>1 byte</td>
</tr>
<tr>
<td>127</td><td>0x7F</td><td>7F</td><td>1 byte</td>
</tr>
<tr>
<td>128</td><td>0x80</td><td>80 01</td><td>2 bytes</td>
</tr>
<tr>
<td>300</td><td>0x012C</td><td>AC 02</td><td>2 bytes</td>
</tr>
<tr>
<td>16384</td><td>0x4000</td>
<td>80 80 01</td><td>3 bytes</td>
</tr>
</tbody>
</table>
</section>

<section anchor="maximum-varint-size">
<name>Maximum Varint Size</name>
<t>Implementations MUST support varints up to 10 bytes,
representing values up to 2^64 - 1 (the full uint64 range).
A 64-bit unsigned integer requires at most 10 varint bytes
(ceil(64/7) = 10). A receiver that encounters a varint that
does not terminate within 10 bytes MUST treat it as a decode
error and discard the data.</t>
</section>

</section>

<section anchor="size-limits"><name>Size Limits</name>
<ul>
<li><strong>Strings and binary data:</strong> The maximum
byte length is constrained by the varint encoding (up to
2^64 - 1). In practice, implementations SHOULD impose
limits appropriate to available memory. A RECOMMENDED
limit for constrained devices is 65,535 bytes (16-bit
addressable).</li>
<li><strong>Maps and arrays:</strong> Element counts have
no protocol-enforced limit beyond the varint range, but
implementations SHOULD impose reasonable limits based on
available memory.</li>
<li><strong>Nesting depth:</strong> Implementations SHOULD
limit nesting depth to prevent stack overflow. A limit of
16 levels is RECOMMENDED for constrained devices.</li>
</ul>
</section>

<section anchor="encoding-optimizations">
<name>Encoding Optimizations</name>
<t>The following optimizations are RECOMMENDED for encoders
but are not required for protocol conformance. A conformant
decoder MUST be able to decode data produced by any
conformant encoder, whether or not these optimizations are
applied.</t>

<section anchor="float-to-integer-promotion">
<name>Float-to-Integer Promotion</name>
<t>When encoding a floating-point number that has no
fractional part and whose absolute value fits within the
uint64 range (0 to 2^64-1),
implementations SHOULD encode it as an unsigned integer
(wire type 0) or signed integer (wire type 1) instead of a
float (wire type 2). This saves 3-7 bytes per value and
exploits the fact that many IoT sensor readings are whole
numbers (e.g., humidity percentages, relay states,
counters).</t>
<t>This promotion MUST NOT be applied to negative zero
(-0.0), NaN, or Infinity values.</t>
<t>A decoder that receives an integer where a float was
expected MUST convert it to the appropriate floating-point
type. This promotion is transparent to the
application.</t>
<t>Size savings:</t>
<table>
<thead>
<tr>
<th>Value</th>
<th>As float (type 2)</th>
<th>As integer (type 0/1)</th>
<th>Savings</th>
</tr>
</thead>
<tbody>
<tr>
<td>0.0</td><td>5 bytes</td>
<td>1 byte</td><td>4 bytes</td>
</tr>
<tr>
<td>25.0</td><td>5 bytes</td>
<td>1 byte</td><td>4 bytes</td>
</tr>
<tr>
<td>-3.0</td><td>5 bytes</td>
<td>1 byte</td><td>4 bytes</td>
</tr>
<tr>
<td>100.0</td><td>5 bytes</td>
<td>3 bytes</td><td>2 bytes</td>
</tr>
</tbody>
</table>
<t><strong>Note:</strong> CBOR's core specification
(<xref target="RFC8949"/>, Section 4.2.2) treats
float-to-integer promotion as optional and
application-dependent. PSON follows the same approach:
this optimization is RECOMMENDED but not required for
conformance.</t>
</section>

<section anchor="precision-selection">
<name>Precision Selection</name>
<t>When encoding a floating-point value that requires
fractional precision, implementations SHOULD choose 32-bit
float (inline value 0) when the float32 representation of
the value is identical to the original float64 value.
Otherwise, 64-bit double (inline value 1) MUST be used.
This saves 4 bytes per value.</t>
<t>More precisely: an encoder SHOULD encode a float64 value
<tt>v</tt> as float32 if and only if
<tt>(float64)(float32)v == v</tt> (the round-trip through
float32 preserves the exact value).</t>
</section>

</section>

<section anchor="pson-vs-json-size-comparison">
<name>PSON vs JSON Size Comparison</name>
<t>The following table compares encoded sizes for common
IoT data patterns:</t>
<table>
<thead>
<tr>
<th>Data</th>
<th>JSON (bytes)</th>
<th>PSON (bytes)</th>
<th>Savings</th>
</tr>
</thead>
<tbody>
<tr>
<td>25</td><td>2</td><td>1</td><td>50%</td>
</tr>
<tr>
<td>true</td><td>4</td><td>1</td><td>75%</td>
</tr>
<tr>
<td>null</td><td>4</td><td>1</td><td>75%</td>
</tr>
<tr>
<td>"hello"</td><td>7</td><td>6</td><td>14%</td>
</tr>
<tr>
<td>{"temp":25,"hum":60}</td>
<td>20</td><td>13</td><td>35%</td>
</tr>
<tr>
<td>{"temp":25.3,"hum":60.1,"co2":412}</td>
<td>34</td><td>27</td><td>21%</td>
</tr>
<tr>
<td>[1,2,3,4,5]</td>
<td>11</td><td>6</td><td>45%</td>
</tr>
</tbody>
</table>
<t>Note: PSON sizes assume float32 precision for
floating-point values, which is typical for IoT sensor
data. JSON sizes use compact encoding with no
whitespace.</t>
<t>For typical IoT payloads (maps with numeric sensor
values), PSON achieves 21-75% size reduction compared
to JSON.</t>
</section>

<section anchor="complete-encoding-examples">
<name>Complete Encoding Examples</name>

<section anchor="simple-sensor-reading">
<name>Simple Sensor Reading</name>
<t>JSON: {"temperature": 23.5, "humidity": 60}</t>
<artwork><![CDATA[
C2                        # map_t, 2 entries
  8B 74 65 6D 70 65 72 61 # string "temperature"
     74 75 72 65           #   (key, 11 bytes)
  40 00 00 BC 41           # float 23.5 (32-bit, LE)
  88 68 75 6D 69 64 69     # string "humidity"
     74 79                 #   (key, 8 bytes)
  1F 3C                    # unsigned 60 (varint)
]]></artwork>
<t>JSON size: 34 bytes. PSON size: 29 bytes. Savings:
15%.</t>
</section>

<section anchor="device-credentials">
<name>Device Credentials</name>
<t>JSON: ["user", "device1", "secretkey"]</t>
<artwork><![CDATA[
E3                        # array_t, 3 elements
  84 75 73 65 72          # string "user" (4 bytes)
  87 64 65 76 69 63 65 31 # string "device1" (7 bytes)
  89 73 65 63 72 65 74    # string "secretkey"
     6B 65 79             #   (9 bytes)
]]></artwork>
<t>JSON size: 30 bytes. PSON size: 24 bytes. Savings:
20%.</t>
</section>

<section anchor="boolean-configuration">
<name>Boolean Configuration</name>
<t>JSON: {"enabled": true, "debug": false}</t>
<artwork><![CDATA[
C2                        # map_t, 2 entries
  87 65 6E 61 62 6C 65 64 # string "enabled" (7 bytes)
  61                      # true
  85 64 65 62 75 67       # string "debug" (5 bytes)
  60                      # false
]]></artwork>
<t>JSON size: 30 bytes. PSON size: 17 bytes. Savings:
43%.</t>
</section>

<section anchor="nested-structure">
<name>Nested Structure</name>
<t>JSON: {"gps": {"lat": 40.4168, "lon": -3.7038},
"alt": 650}</t>
<artwork><![CDATA[
C2                        # map_t, 2 entries
  83 67 70 73             # string "gps" (3 bytes)
  C2                      # map_t, 2 entries (nested)
    83 6C 61 74           # string "lat" (3 bytes)
    41 85 7C D0 B3        # double 40.4168
       59 35 44 40        #   (64-bit, LE)
    83 6C 6F 6E           # string "lon" (3 bytes)
    41 FE 65 F7 E4        # double -3.7038
       61 A1 0D C0        #   (64-bit, LE)
  83 61 6C 74             # string "alt" (3 bytes)
  1F 8A 05                # unsigned 650 (varint)
]]></artwork>
<t>JSON size: 47 bytes. PSON size: 39 bytes. Savings:
17%.</t>
<t>(Note: GPS coordinates require double precision,
limiting compaction. Integer values like altitude benefit
most from PSON encoding.)</t>
</section>

</section>

<section anchor="security-considerations">
<name>Security Considerations</name>

<section anchor="input-validation">
<name>Input Validation</name>
<t>Implementations MUST validate PSON data during
decoding:</t>
<ul>
<li><strong>Length bounds:</strong> String and binary
lengths MUST NOT exceed the implementation's maximum
<xref target="size-limits"/>. A decoder MUST NOT
allocate memory based on an untrusted length field without
bounds checking.</li>
<li><strong>Nesting depth:</strong> Deeply nested maps and
arrays can cause stack overflow on constrained devices.
Implementations SHOULD enforce a maximum nesting
depth.</li>
<li><strong>Map key uniqueness:</strong> Implementations
SHOULD reject maps with duplicate keys, as they may
indicate an injection attempt.</li>
<li><strong>Varint termination:</strong> A varint that does
not terminate (every byte has the continuation bit set)
constitutes a denial-of-service vector. Implementations
MUST enforce the maximum varint length
<xref target="maximum-varint-size"/>.</li>
<li><strong>Wire type validation:</strong> A decoder MUST
reject values with reserved inline values (e.g.,
floating_t with inline value &gt; 1, discrete_t with
inline value &gt; 2).</li>
</ul>
</section>

<section anchor="denial-of-service">
<name>Denial of Service</name>
<t>PSON data from untrusted sources can be crafted to
consume excessive resources:</t>
<ul>
<li><strong>Large allocations:</strong> A malicious length
prefix can trigger large memory allocations.
Implementations MUST impose application-appropriate limits
and MUST NOT allocate memory based solely on untrusted
length fields.</li>
<li><strong>Processing time:</strong> Deeply nested
structures can cause excessive processing time. Depth
limits <xref target="size-limits"/> mitigate
this.</li>
</ul>
</section>

<section anchor="confidentiality">
<name>Confidentiality</name>
<t>PSON does not provide encryption. When transmitting
sensitive data, PSON MUST be used within an encrypted
transport (e.g., TLS).</t>
</section>

</section>

<section anchor="iana-considerations">
<name>IANA Considerations</name>

<section anchor="media-type-registration">
<name>Media Type Registration</name>
<t>This specification requests registration of the
following media type in the "Media Types" registry:</t>
<ul spacing="compact">
<li>Type name: application</li>
<li>Subtype name: pson</li>
<li>Required parameters: none</li>
<li>Optional parameters: none</li>
<li>Encoding considerations: binary</li>
<li>Security considerations: See
<xref target="security-considerations"/> of this
document</li>
<li>Interoperability considerations: PSON uses
little-endian byte ordering for floating-point values,
which differs from CBOR and most Internet protocols.
Implementations on big-endian platforms MUST perform byte
swapping when encoding and decoding floats and
doubles.</li>
<li>Published specification: This document</li>
<li>Applications that use this media type: IoT device
communication, sensor data encoding, IOTMP protocol
payloads</li>
<li>Fragment identifier considerations: N/A</li>
<li>Contact: Alvaro Luis Bustamante,
alvaro@thinger.io</li>
<li>Change controller: IETF</li>
</ul>
</section>

</section>

</middle>

<back>
<references><name>References</name>
<references><name>Normative References</name>
<reference anchor="IEEE754" target="">
  <front>
    <title>IEEE Standard for Floating-Point
    Arithmetic</title>
    <author>
      <organization>IEEE</organization>
    </author>
    <date year="2019"/>
  </front>
  <seriesInfo name="IEEE" value="754-2019"/>
</reference>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.2119.xml"/>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.8174.xml"/>
</references>
<references><name>Informative References</name>
<reference anchor="MessagePack"
           target="https://msgpack.org/">
  <front>
    <title>MessagePack specification</title>
    <author fullname="Sadayuki Furuhashi"
            initials="S." surname="Furuhashi"/>
  </front>
</reference>
<reference anchor="ProtocolBuffers"
  target="https://protobuf.dev/programming-guides/encoding/">
  <front>
    <title>Protocol Buffers Encoding</title>
    <author>
      <organization>Google</organization>
    </author>
  </front>
</reference>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.8259.xml"/>
<xi:include href="https://bib.ietf.org/public/rfc/bibxml/reference.RFC.8949.xml"/>
</references>
</references>

<section anchor="conformance-test-vectors">
<name>Conformance Test Vectors</name>
<t>The following test vectors allow implementations to
verify correct encoding and decoding. Each entry shows a
logical value and its expected PSON encoding in
hexadecimal.</t>

<section anchor="integer-test-vectors">
<name>Integer Test Vectors</name>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td>Unsigned 0</td><td>00</td><td>1 byte</td>
</tr>
<tr>
<td>Unsigned 25</td><td>19</td><td>1 byte</td>
</tr>
<tr>
<td>Unsigned 30</td><td>1E</td><td>1 byte</td>
</tr>
<tr>
<td>Unsigned 31</td><td>1F 1F</td><td>2 bytes</td>
</tr>
<tr>
<td>Unsigned 300</td><td>1F AC 02</td><td>3 bytes</td>
</tr>
<tr>
<td>Signed -1</td><td>21</td><td>1 byte</td>
</tr>
<tr>
<td>Signed -30</td><td>3E</td><td>1 byte</td>
</tr>
<tr>
<td>Signed -300</td><td>3F AC 02</td><td>3 bytes</td>
</tr>
</tbody>
</table>
</section>

<section anchor="floating-point-test-vectors">
<name>Floating-Point Test Vectors</name>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td>Float 23.5</td>
<td>40 00 00 BC 41</td>
<td>5 bytes</td>
</tr>
<tr>
<td>Float 3.14</td>
<td>40 C3 F5 48 40</td>
<td>5 bytes</td>
</tr>
<tr>
<td>Double 3.141592653</td>
<td>41 38 E9 2F 54 FB 21 09 40</td>
<td>9 bytes</td>
</tr>
</tbody>
</table>
</section>

<section anchor="discrete-test-vectors">
<name>Discrete Test Vectors</name>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr><td>False</td><td>60</td><td>1 byte</td></tr>
<tr><td>True</td><td>61</td><td>1 byte</td></tr>
<tr><td>Null</td><td>62</td><td>1 byte</td></tr>
</tbody>
</table>
</section>

<section anchor="string-test-vectors">
<name>String Test Vectors</name>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td>"" (empty)</td>
<td>80</td>
<td>1 byte</td>
</tr>
<tr>
<td>"hi"</td>
<td>82 68 69</td>
<td>3 bytes</td>
</tr>
<tr>
<td>"temperature"</td>
<td>8B 74 65 6D 70 65 72 61 74 75 72 65</td>
<td>12 bytes</td>
</tr>
</tbody>
</table>
</section>

<section anchor="container-test-vectors">
<name>Container Test Vectors</name>
<table>
<thead>
<tr>
<th>Value</th>
<th>Encoded (hex)</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr>
<td>Empty map {}</td><td>C0</td><td>1 byte</td>
</tr>
<tr>
<td>Empty array []</td><td>E0</td><td>1 byte</td>
</tr>
<tr>
<td>Array [1,2,3]</td>
<td>E3 01 02 03</td><td>4 bytes</td>
</tr>
</tbody>
</table>
</section>

<section anchor="composite-test-vector">
<name>Composite Test Vector</name>
<t>Map {"temp": 25, "hum": 60} -- 13 bytes total:</t>
<artwork><![CDATA[
C2                  # map_t, 2 entries
  84 74 65 6D 70    # string "temp" (key, 4 bytes)
  19                # unsigned 25 (value)
  83 68 75 6D       # string "hum" (key, 3 bytes)
  1F 3C             # unsigned 60 (varint)
]]></artwork>
<t>Hex: C2 84 74 65 6D 70 19 83 68 75 6D 1F 3C</t>
</section>

</section>

<section anchor="implementation-complexity-comparison">
<name>Implementation Complexity Comparison</name>
<t>This appendix provides a quantitative comparison of
implementation complexity between PSON and two widely-used
CBOR libraries for constrained devices. All measurements
use the same test payload:
{"temperature": 23.5, "humidity": 60,
"pressure": 1013, "label": "outdoor"}.</t>

<section anchor="source-code-size">
<name>Source Code Size</name>
<table>
<thead>
<tr>
<th>Implementation</th>
<th align="right">Source Lines (encoder + decoder)</th>
</tr>
</thead>
<tbody>
<tr>
<td>PSON (standalone C)</td>
<td align="right">344</td>
</tr>
<tr>
<td>NanoCBOR (C, minimal CBOR)</td>
<td align="right">2,223</td>
</tr>
<tr>
<td>TinyCBOR (C, Intel)</td>
<td align="right">5,619</td>
</tr>
</tbody>
</table>
<t>PSON's reduced source size results from its narrower
scope: 8 wire types with a single varint overflow
mechanism, no semantic tags, no indefinite-length
encoding, no half-precision floats, and string-only map
keys.</t>
</section>

<section anchor="compiled-binary-size">
<name>Compiled Binary Size (ESP32, ESP-IDF v5.4)</name>
<t>All projects compiled for ESP32 using
espressif/idf:v5.4 Docker image with default optimization
(-Og). No networking, no TLS -- pure encoding
benchmark.</t>
<table>
<thead>
<tr>
<th>Impl.</th>
<th align="right">Library</th>
<th align="right">App Code</th>
<th align="right">Total</th>
<th align="right">Full Image</th>
</tr>
</thead>
<tbody>
<tr>
<td>PSON</td>
<td align="right">0 B (inline)</td>
<td align="right">2,589 B</td>
<td align="right">2,589 B</td>
<td align="right">195,044 B</td>
</tr>
<tr>
<td>NanoCBOR</td>
<td align="right">2,306 B</td>
<td align="right">1,510 B</td>
<td align="right">3,816 B</td>
<td align="right">196,076 B</td>
</tr>
<tr>
<td>TinyCBOR</td>
<td align="right">4,445 B</td>
<td align="right">2,159 B</td>
<td align="right">6,604 B</td>
<td align="right">200,888 B</td>
</tr>
</tbody>
</table>
<t>PSON's compiled encoding code is 32% smaller than
NanoCBOR and 61% smaller than TinyCBOR for identical
functionality. Full image sizes differ by less than 3%
because the ESP-IDF base (FreeRTOS, HAL, libc) dominates
at ~190 KB.</t>
<t>NanoCBOR is the most minimal CBOR implementation
available for constrained devices. The difference with
TinyCBOR illustrates how CBOR's broader feature set
(validation, pretty-printing, JSON conversion) increases
footprint even when those features are not used by the
application.</t>
</section>

<section anchor="encoded-wire-size">
<name>Encoded Wire Size</name>
<t>All three encodings produce comparable wire sizes for
the same payload:</t>
<table>
<thead>
<tr>
<th>Implementation</th>
<th align="right">Encoded Size</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>PSON</td>
<td align="right">55 bytes</td>
<td>float32 for 23.5</td>
</tr>
<tr>
<td>NanoCBOR</td>
<td align="right">53 bytes</td>
<td>float16 for 23.5 (half-precision)</td>
</tr>
<tr>
<td>TinyCBOR</td>
<td align="right">55 bytes</td>
<td>float32 for 23.5</td>
</tr>
</tbody>
</table>
<t>The 2-byte difference with NanoCBOR is due to CBOR's
half-precision float support (IEEE 754 binary16), which
PSON deliberately omits to avoid the implementation
complexity of float16 conversion routines. For typical IoT
payloads, wire size differences between PSON and CBOR are
negligible.</t>
</section>

<section anchor="encoding-decoding-performance">
<name>Encoding and Decoding Performance
(ESP32, 240 MHz)</name>
<t>All benchmarks executed on the same ESP32 hardware
(Xtensa LX6, 240 MHz, single core used). Each measurement
averages 1,000,000 iterations of encoding or decoding the
test payload.</t>
<table>
<thead>
<tr>
<th>Implementation</th>
<th align="right">Encode (us/iter)</th>
<th align="right">Decode (us/iter)</th>
</tr>
</thead>
<tbody>
<tr>
<td>PSON</td>
<td align="right">10.00</td>
<td align="right">5.93</td>
</tr>
<tr>
<td>NanoCBOR</td>
<td align="right">19.65</td>
<td align="right">16.86</td>
</tr>
<tr>
<td>TinyCBOR</td>
<td align="right">20.04</td>
<td align="right">52.78</td>
</tr>
</tbody>
</table>
<t><strong>Encoding:</strong> PSON encodes approximately
2x faster than both CBOR implementations. PSON's
single-pass encoder with varint overflow requires fewer
branches than CBOR's fixed-width (1/2/4/8 byte) argument
encoding.</t>
<t><strong>Decoding:</strong> PSON decodes 2.8x faster than
NanoCBOR and 8.9x faster than TinyCBOR. The decoder
benefits from the simpler tag structure (no
indefinite-length containers to check, no semantic tags
to skip, no half-precision float conversion, string-only
map keys). TinyCBOR's significantly slower decoding
reflects its more complex parser, which must handle the
full CBOR data model including container tracking and
validation.</t>
<t>These performance differences are a direct consequence
of PSON's reduced format complexity, not implementation
quality -- NanoCBOR is a well-optimized, production-quality
CBOR library specifically designed for constrained
devices.</t>
</section>

<section anchor="benchmark-summary">
<name>Summary</name>
<table>
<thead>
<tr>
<th>Metric</th>
<th>PSON vs NanoCBOR</th>
<th>PSON vs TinyCBOR</th>
</tr>
</thead>
<tbody>
<tr>
<td>Wire size</td>
<td>Comparable (+2 bytes)</td>
<td>Equal</td>
</tr>
<tr>
<td>Compiled code</td>
<td>32% smaller</td>
<td>61% smaller</td>
</tr>
<tr>
<td>Encode speed</td>
<td>2.0x faster</td>
<td>2.0x faster</td>
</tr>
<tr>
<td>Decode speed</td>
<td>2.8x faster</td>
<td>8.9x faster</td>
</tr>
<tr>
<td>Source lines</td>
<td>6.5x fewer</td>
<td>16.3x fewer</td>
</tr>
</tbody>
</table>
<t><strong>Note:</strong> The primary justification for
PSON is not performance superiority over CBOR, but the
architectural integration with the IOTMP protocol --
shared encoding primitives eliminate the need for two
independent type systems on constrained devices (see
<xref target="relationship-to-cbor"/>). The performance
and complexity advantages documented here are a
consequence of that narrower design scope.</t>
</section>

</section>

<section anchor="revision-history"><name>Revision History</name>
<table>
<thead>
<tr>
<th>Version</th>
<th>Date</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td>0.1</td>
<td>2026-03-30</td>
<td>Initial public draft.</td>
</tr>
</tbody>
</table></section>

</back>

</rfc>
