AtlatestRepositorysigil-wire
sigil-wire / tree / docswire.md
1
# The sigil-wire format (v1)3
This is the normative, byte-level specification of the sigil-wire binary format,4
for anyone implementing an encoder or decoder in another language. The reference5
implementation is `src/sigil/wire.sgl`.7
All multi-byte integers are little-endian. Lengths and counts are unsigned8
LEB128 varints. The format carries no schema: every value is self-describing via9
a leading tag byte.11
## Message framing13
```14
message : header value15
header : MAGIC(2) VERSION(1) FLAGS(1)16
```18
| Field | Bytes | Value | Notes |19
|-------|-------|-------|-------|20
| MAGIC | 2 | `0x53 0x57` (`"SW"`) | Rejected if it does not match. |21
| VERSION | 1 | `0x01` | Decoders reject an unknown major version. |22
| FLAGS | 1 | `0x00` | Reserved; currently ignored on decode. |24
Exactly one root `value` follows the header. Any bytes remaining after the root25
value has been fully decoded are an error (a frame carries one value).27
## Varints (unsigned LEB128)29
A non-negative integer is encoded as a sequence of 7-bit groups, least30
significant first. Each byte holds 7 payload bits in its low bits; the high bit31
(`0x80`) is set on every byte except the last.33
```34
encode(u):35
loop:36
byte = u mod 12837
u = u div 12838
if u == 0: emit(byte); break39
else: emit(byte | 0x80)41
decode:42
result = 0; mult = 143
loop:44
b = next_byte()45
result += (b mod 128) * mult46
if b < 0x80: break47
mult *= 12848
```50
Examples: `0 → 00`, `1 → 01`, `127 → 7F`, `128 → 80 01`, `300 → AC 02`.52
> Implementation note: the reference decoder accumulates with integer53
> multiply/add rather than shift/or. This is deliberate — Sigil's bitwise-shift54
> primitives corrupt values crossing the fixnum/bignum boundary. Other languages55
> may use shift/or freely.57
A decoder MUST cap varint length. The reference caps length/count varints at 1058
bytes and integer-payload varints at `max-int-bytes` bytes, and additionally59
range-checks decoded lengths/counts against the remaining buffer before use.61
## Values63
Each value begins with a 1-byte tag.65
| TAG | Type | Payload |66
|-----|------|---------|67
| `0x00` | `#f` / nil | none |68
| `0x01` | `#t` | none |69
| `0x02` | int | zigzag varint |70
| `0x03` | float | 8 bytes IEEE-754 LE |71
| `0x04` | string | varint N + N UTF-8 bytes |72
| `0x05` | bytevector | varint N + N raw bytes |73
| `0x06` | keyword | varint N + N UTF-8 bytes |74
| `0x07` | symbol | varint N + N UTF-8 bytes |75
| `0x08` | char | varint codepoint |76
| `0x09` | list | varint K + K values |77
| `0x0A` | dict | varint K + K (key value) pairs |78
| `0x0B` | vector | varint K + K values |79
| `0x0C` | array | varint K + K values |81
A tag not in this table is a hard error. Decoders MUST NOT attempt to skip an82
unknown tag: without a length the parser cannot know how many bytes to skip, and83
guessing cascades into misparsing the rest of the frame.85
### 0x02 int — zigzag varint87
A single format covers all integers, including arbitrary-precision bignums.88
Zigzag maps signed to unsigned so small-magnitude negatives stay short:90
```91
zigzag(n) = 2n if n >= 092
2|n| - 1 if n < 093
unzigzag(u) = u/2 if u even94
-(u/2) - 1 if u odd (integer division)95
```97
The zigzagged value is then written as an unsigned varint.99
Examples: `0 → 02 00`, `-1 → 02 01`, `1 → 02 02`, `-2 → 02 03`, `63 → 02 7E`.101
### 0x03 float — IEEE-754 double103
Eight bytes holding the raw IEEE-754 binary64 bit pattern, little-endian. Sign in104
the top bit of the last (most significant) byte.106
- `+inf` = `7FF0000000000000`, `-inf` = `FFF0000000000000`.107
- `NaN`: any payload with the exponent field all-ones and a non-zero fraction is108
a NaN. The reference implementation writes whatever bits the host double109
carries and, on decode, canonicalizes a bit pattern that would collide with the110
runtime's tagged-value space to a safe quiet NaN (see below).111
- `-0.0` = `8000000000000000` is represented distinctly and round-trips112
bit-exactly (the reference implementation uses the host's native IEEE accessor,113
which preserves the sign bit — even though the language's numeric predicates114
cannot distinguish `-0.0` from `+0.0`).116
Example: `3.14 → 03 1F 85 EB 51 B8 1E 09 40` (bytes are `0x40091EB851EB851F` LE).118
### 0x04 string / 0x05 bytevector / 0x06 keyword / 0x07 symbol120
A varint byte-count `N` followed by `N` bytes copied verbatim. Strings, keywords121
and symbols carry UTF-8; bytevectors carry raw bytes. The decoder checks `N`122
against the remaining buffer before allocating, then copies the body in one123
`memcpy`. Keywords and symbols are interned from their UTF-8 name on decode.125
Example: `"hi" → 04 02 68 69`.127
### 0x08 char — codepoint varint129
A varint Unicode scalar value. Decoders should reject values above `0x10FFFF`.131
Example: `#\A → 08 41`.133
### 0x09 list / 0x0B vector / 0x0C array — counted sequences135
A varint element-count `K` followed by `K` encoded values in order. Lists, R7RS136
vectors (`#(...)`) and Sigil arrays (`#[...]`) share this shape but use distinct137
tags so their type is preserved across a round-trip.139
Example: `(1 2 3) → 09 03 02 02 02 04 02 06`.141
### 0x0A dict — counted key/value pairs143
A varint pair-count `K` followed by `2K` encoded values: `key`, `value`, `key`,144
`value`, … Keys are ordinary values (usually keywords) and are encoded with their145
own tag. Dict equality is order-independent, so the reference implementation does146
not commit to a key order.148
Example: `#{ a: 1 } → 0A 01 06 01 61 02 02` (keyword `a`, then int `1`).150
## Decoder safety requirements152
An implementation reading untrusted bytes MUST:154
1. Validate MAGIC and VERSION before anything else.155
2. Read every length/count into a bounded varint, then range-check it against the156
remaining buffer **before** allocating or copying.157
3. Enforce configurable limits: maximum single-body length, maximum collection158
count, maximum nesting depth, maximum total decoded size, maximum integer159
magnitude — rejecting **before** allocation. The total-size limit should160
charge collection allocation (per-element slot cost), not only string/byte161
bodies, or a collection-heavy frame escapes the bound.162
4. Use a bounds-checked cursor so a truncated or lying frame errors cleanly and163
never reads out of bounds.164
5. Treat an unknown tag, unknown version, or trailing bytes as hard errors.165
6. If decode is recursive, the nesting-depth limit also bounds host stack depth;166
keep its default modest so a deeply-nested frame cannot overflow the stack.167
7. Decode a malformed UTF-8 string/keyword/symbol body with defined behavior168
(lenient replacement, or a clean error) — never a crash or OOB read.170
## Hardening for untrusted remote transport172
Interning a decoded keyword/symbol name is permanent in some hosts (the intern173
table is not collected). A hostile peer streaming endless unique names can174
exhaust memory — a risk the per-frame caps do not bound. For untrusted *remote*175
traffic, gate symbol/keyword decoding (decode as strings unless a schema opts in)176
at the transport layer. For local in-process payloads this does not apply.178
## Versioning180
The 1-byte VERSION is the format's major version. A decoder rejects a version it181
does not implement rather than guessing. New tags or payload shapes that are not182
backward-compatible bump the version; a capability handshake at connect time (for183
Familiar/Enclave) negotiates it so a new client and an old node degrade184
gracefully.