LATTICE — Lattice Container Format (.lttc)
Источник:
dev/fera/lattice/SPEC.md(read-only; сгенерировано scripts/sync-docs.mjs)
LATTICE — Lattice Container Format (.lttc)
Section titled “LATTICE — Lattice Container Format (.lttc)”Status: Draft v1 (frozen) — supersedes Draft v0.1 (2026-09-30).
All byte layouts in this document are the canonical encoding. Changes to
anything marked frozen require a format_version bump per §3.2.
Audience: Lattice implementers, Fera plugin loader, third-party tooling.
Companion docs (Fera design corpus):
ADR.md
(ADR-021 distribution manifest, ADR-022 translators),
OQ.md
(OQ-013 — origin of this format),
NAMING-UX.md
§1 (naming: Lattice / LTT Container / .lttc).
Lattice is a standalone project, created for Fera but not Fera-internal. It is a universal plugin-and-manifest container format: any host may use it, with or without native code, under its own trust rules. For Fera specifically: it packages plugins and their runtimes (WASM), while Fera’s own native modules stay plain signed dylibs per ADR-021 — a Fera-side loading policy, not a format limitation.
The name is literal: the Merkle DAG at the core of the format is a lattice (решётка).
1. Design goals
Section titled “1. Design goals”The format exists to give a WASM plugin package these properties:
-
Transitive integrity. Sign the Merkle root once → the whole container (manifest, all wasm components, all assets, all translations) is transitively covered. The ADR-021 “signature covers the whole entry” hardening comes for free; a translator (ADR-022) physically cannot substitute any content without breaking the root.
-
Lazy per-block verification. A loader never has to hash the whole file up front. It hashes each block as it is actually read and checks the hash against the block’s CID. First paint of a plugin UI does not wait for a 200 MB asset pack to finish hashing.
-
Content-addressed dedup. Blocks are addressed by content hash (CID-style). Identical chunks across versions of one plugin, or across unrelated plugins (shared fonts, shared translations, common assets), are stored once in a shared on-disk block store.
-
Incremental updates. v1 → v2 of a plugin is a fetch of the changed blocks only (OSTree-pull / casync-style). Layout choices (see §9) keep the patch small by design.
-
mmap-friendly. Blocks are aligned on 4 KiB boundaries so a loader can
mmapthe container and let the OS manage residency, instead of copying the whole image into an in-memory buffer. -
Fast manifest path. A loader reads the manifest (plugin name, version, requested capabilities, subtree roots, entry points) without touching the block table or walking the Merkle DAG. Two direct pointers in the superblock make this an O(1) seek + read.
-
Headroom for the future. The format carries features from day one (feature flags, multi-root subtrees, per-subtree signatures, detached signature mode, SBOM section) rather than growing them ad hoc. An old loader must refuse politely on unknown must-understand features.
Non-goals:
- Be a general-purpose filesystem. It is a plugin package, not a squashfs replacement.
- Be streamable / tape-friendly. Random access is assumed.
- Compress aggressively at the cost of complexity. Compression is a per-block tradeoff (see §7), not the format’s reason to exist.
2. Container layout — high level
Section titled “2. Container layout — high level”A .lttc file is a single image with these regions, in order. All
offsets are byte offsets from file start; all multi-byte fields are
little-endian (frozen, Draft v1).
┌──────────────────────────────────────────────────────────────────┐│ Superblock (fixed 4 KiB, uncompressed) ││ - magic, version, feature flags ││ - direct pointers: manifest / license / pointer-table / ││ block-table / content-blocks regions ││ - 4 signature slots, detached-sig pointer (optional) │├──────────────────────────────────────────────────────────────────┤│ Manifest region (uncompressed, canonical RON) ││ - directly pointed to by superblock; the ONLY manifest │├──────────────────────────────────────────────────────────────────┤│ License / provenance / SBOM region (uncompressed, SPDX) ││ - optional, directly pointed to by superblock │├──────────────────────────────────────────────────────────────────┤│ Pointer table (optional acceleration index) ││ - directly pointed to by superblock │├──────────────────────────────────────────────────────────────────┤│ Block table (CID → offset, len, flags) │├──────────────────────────────────────────────────────────────────┤│ Content-block region (payload) ││ - raw content blocks: wasm components, assets, translations ││ - FS-metadata nodes and Merkle internal nodes ALSO live here ││ as ordinary content-addressed blocks (no separate region) ││ - per-block deflate, 4 KiB-aligned starts │└──────────────────────────────────────────────────────────────────┘Region order is a spec decision; loaders MUST use the pointers in the superblock, not assume linear layout. The order above is the recommended canonical writer layout because it keeps the small hot regions (superblock, manifest) at the head of the file and the bulk payload at the tail — incremental readers and HTTP range-request servers both benefit.
On regions that used to be separate: Draft v0.1 sketched distinct “FS metadata region” and “Merkle DAG nodes” regions. Draft v1 folds them into the content-block region: FS-metadata nodes (codec 0x02) and Merkle internal nodes (codec 0x05) are stored, addressed, and lazily verified exactly like payload blocks, located through the block table. This removes two locator fields from the superblock and one region class from the reader.
Block store vs container. The shared on-disk block store (cross-
container dedup pool, GC) is host-defined and out of scope for this
spec. The container format only guarantees content-addressability that
makes such stores possible. A future lttc-store spec may standardize
one.
3. Superblock
Section titled “3. Superblock”Fixed-size, at offset 0. Size is 4 KiB so the whole superblock is
always one aligned read and is itself 4 KiB-mmap-friendly. The superblock
carries its own size in header_size — even with the size fixed at 4 KiB
for this format version, a future format revision may change it, and an
old reader can then still find where its known fields end.
Byte layout (frozen, Draft v1; all integers little-endian):
| Offset | Size | Field |
|---|---|---|
| 0 | 16 | Magic: b"LTTC_BUNDLE >^<\0" |
| 16 | 4 | header_size (u32) — 4096 in this version |
| 20 | 4 | format_version_major (u32) — 1 |
| 24 | 4 | format_version_minor (u32) — 0 |
| 28 | 1 | hash_alg (u8) — 0x02 = BLAKE3 (see §6) |
| 29 | 1 | sig_alg (u8) — 0x01 = Ed25519 (see §5.4) |
| 30 | 2 | Reserved (zero) |
| 32 | 8 | feature_flags (u64 bitset, see §3.1) |
| 40 | 8 | manifest_offset (u64) |
| 48 | 8 | manifest_length (u64) |
| 56 | 8 | block_table_offset (u64) |
| 64 | 8 | block_table_length (u64) |
| 72 | 32 | fs_metadata_root_digest (bare 32-byte digest, see §6) |
| 104 | 8 | license_region_offset (u64; zero if absent) |
| 112 | 8 | license_region_length (u64) |
| 120 | 8 | pointer_table_offset (u64; zero if absent) |
| 128 | 8 | pointer_table_length (u64) |
| 136 | 8 | content_blocks_offset (u64, first aligned block) |
| 144 | 8 | total_file_size (u64; MUST equal the actual file size) |
| 152 | 8 | Reserved (zero) — aligns the signature slots to 8 |
| 160 | 64 | root_signature slot 0 (code subtree; zero if absent) |
| 224 | 32 | root_signature_key_id slot 0 (zero if absent) |
| 256 | 64 | root_signature slot 1 (translations; zero if absent) |
| 320 | 32 | root_signature_key_id slot 1 |
| 352 | 64 | root_signature slot 2 (themes; zero if absent) |
| 416 | 32 | root_signature_key_id slot 2 |
| 448 | 64 | root_signature slot 3 (SBOM/license; zero if absent) |
| 512 | 32 | root_signature_key_id slot 3 |
| 544 | 8 | detached_signature_offset (u64; zero if absent) |
| 552 | 8 | detached_signature_length (u64) |
| 560 | 3536 | Reserved tail (zero-filled; see rule below) |
Sanity: 560 + 3536 = 4096. Every multi-byte field sits on its natural alignment (u32 on 4, u64 on 8) — the reader decodes with plain little-endian loads, no unaligned-access traps.
Signed prefix. signed_prefix = superblock[0..160] — everything from
the magic through total_file_size (and its aligning pad). All four
signatures sign a message that commits to this prefix (§5.3), so every
locator, version, feature flag, and the FS-metadata root digest is
authenticated; nothing an attacker can rewrite in the head of the file
goes unnoticed.
Signature slots. 4 slots — 0: code, 1: translations, 2: themes,
3: SBOM/license. Each slot is a 64-byte Ed25519 signature (§5.4) plus a
32-byte key_id. A slot is absent iff its signature bytes are all zero
(and then its key_id MUST also be zero).
Reserved tail. Offsets 560..4096 (3536 bytes) stay zero-filled. New
features consume the tail via format_version_minor bumps. The zero-check
is scoped to the reader’s known minor: a reader errors on non-zero
reserved bytes only when format_version_minor is one it implements;
for a higher minor the file is refused by the version check, not the
zero-check (§3.2). If the reserve ever runs out, that is a major format
revision — bump header_size (the field exists precisely for this).
3.1 Feature flags
Section titled “3.1 Feature flags”feature_flags is a 64-bit bitset. Each defined bit is a
must-understand feature: a reader that sees an unknown bit set MUST
refuse to load with LttcError::UnknownFeatureBit, not silently ignore
it. This is the “old loaders refuse politely” mechanism.
Defined in Draft v1:
| Bit | Meaning |
|---|---|
| 0 | HAS_LICENSE_REGION — license/SBOM region is present |
| 1 | HAS_PER_SUBTREE_SIGNATURES — any of signature slots 1..3 is in use |
| 2 | HAS_DETACHED_SIGNATURE — the detached-sig pointer is in use |
| 3 | MULTI_ROOT — the manifest’s roots[] lists more than one root |
| 4–31 | Reserved; spec-assigned only (see allocation rule); must-error |
| 32–63 | Vendor-experimental subrange; a reader that knows no vendor mapping treats them as unknown must-understand bits and refuses |
Allocation. Bits 0–31 are assignable only by a Lattice spec release (spec-repo PR + minor bump). Bits 32–63 are a vendor-experimental subrange; promoting one to official requires a spec release. Assigned bits are never reused.
Adding a feature that an old loader cannot safely ignore requires allocating a new bit. Adding a fully backward-compatible hint does not need a bit and belongs in the manifest instead.
3.2 Versioning and canonical parse order
Section titled “3.2 Versioning and canonical parse order”format_version_major is bumped on any change that breaks a reader
written against the previous major. format_version_minor is bumped on
backward-compatible extensions (new fields carved out of the reserved
tail, new must-understand bits a reader refuses by name).
A reader MUST run these checks in this order — cheap rejects before any attacker-influenced allocation, dereference, or signature work:
- Magic —
LttcError::BadMagic. header_size(≥ the reader’s known field span, ≤ file size).format_version_major—LttcError::UnsupportedMajor.format_version_minor—LttcError::UnsupportedMinorif higher than the reader implements.feature_flagsagainst the reader’s known-bit mask —LttcError::UnknownFeatureBit(lowest unknown bit); the error message lists all unknown bits.- Reserved-tail zero check, scoped to the known minor (§3) —
LttcError::NonZeroReserved { offset }. total_file_sizeMUST equal the actual file size —LttcError::SizeMismatch.- Structural bounds: every
offset + lengthpair lies within the file; regions do not overlap —LttcError::RegionOverlap. - Signature verification (§5.4).
- Lazy per-block verification on access (§6).
Steps 1–8 are pure arithmetic on the superblock and complete before any hash or signature work.
4. Manifest region
Section titled “4. Manifest region”The manifest is the single flat manifest document — there is no second, Merkle-tree form:
- Stored uncompressed. It must stay readable and greppable with
plain
less/strings/grepagainst the raw.lttcfile. No decompressor, no hash-walking. (Greppability assumes the recommended canonical writer layout that keeps the manifest near the head.) - Addressed by two direct pointers in the superblock
(
manifest_offset,manifest_length). A loader reads the manifest with exactly onepreadafter parsing the superblock — it never touches the block table, never walks the Merkle DAG. - Authenticated via the signed prefix: the superblock’s
manifest_offset/manifest_lengthlive insidesigned_prefix(§3), so any rewrite of the manifest region breaks every signature (the manifest bytes are committed transitively through the FS-metadata super-node, §5.3 — the manifest content files are addressed under thepluginroot’s/manifest/directory and hashed there). A reader MUST verify at least one signature before trusting manifest contents; the fast path is onepread+ one Ed25519 verify, still no block table.
Rationale: the manifest is what the host reads to decide whether to load
the plugin at all (name, version, requested capabilities, ADR-021
host.{min,max}_version constraints). Every millisecond spent hashing
or decompressing before that decision is wasted on plugins that will be
refused.
Format: RON inside the bundle. RON round-trips Rust types directly and is what the build tooling (§8) emits. The human-edited view outside the bundle is TOML; the CLI converts TOML → RON at pack time and RON → TOML at unpack/inspect time. Two formats are deliberate — RON for the machine, TOML for the human.
4.1 Canonical RON dialect (frozen)
Section titled “4.1 Canonical RON dialect (frozen)”Because manifest bytes are hashed, serialization MUST be byte-unique for
a given value. Writers MUST use the ron crate 0.8.x with exactly
this configuration, and readers MUST NOT accept documents that deviate:
PrettyConfigwithstructural_names = true,depth_limit = 64;- no extensions (in particular
implicit_somedisabled); - fields emitted in declared struct order (never map-iteration order);
- maps, where they appear, sorted by raw key bytes ascending;
- UTF-8, no BOM,
\nline endings, single trailing newline.
4.2 Manifest schema (v1, frozen)
Section titled “4.2 Manifest schema (v1, frozen)”One document of type Manifest:
Manifest( format_manifest_version: 1, plugin: ( id: "com.example.fera.weather", // reverse-DNS, unique name: "Weather", version: "1.4.2", // semver → ADR-021 module_version api_edition: 1, // ADR-021 api_edition abi_version: 1, // ADR-021 abi_version ), host: (min_version: "0.9.0", max_version: None), entry_points: [ (kind: Component, path: "/code/wasm/components/main/component.wasm"), ], roots: [ (name: Plugin, digest: "…"), // closed enum, see below (name: Translations, digest: "…"), (name: Themes, digest: "…"), ], capabilities_requested: ["messages.read", "ui.badge.set"], depends_on: [], // [{id, version_req}] artifacts: [], // per-target native/component builds signatures: [], // mirrors of superblock slots issued_at: 0, // unix seconds (anti-downgrade) expires_at: None,)roots[].name— closed v1 enum{Plugin, Translations, Themes}. This array is the root enumeration: a root not listed is unreachable, andMULTI_ROOT(§3.1) is set iffroots.len() > 1.entry_points[].kind— closed v1 enum{Component, Native}.capabilities_requested— capability registry is an appendix list, extended by spec minor bumps (§5.2 for the capability→subtree map).artifacts[]— per-target builds:{target: {triple, features: [String]}, path, digest: [u8; 32]}.signatures[]—{slot: u8, key_id: [u8; 32], algorithm, value: [u8; 64]}; informational mirrors of the superblock slots so the manifest alone tells a host which keys signed which subtree.issued_at/expires_at— downgrade-protection window.
A plugin is multiple wasm files, not one — component-style
composition. The entry_points array lists them; partial updates then
touch only the components that were actually rebuilt.
5. FS metadata
Section titled “5. FS metadata”A small virtual filesystem. Files are addressed by directory paths
inside the container (see §5.1 for the canonical layout), not by CID
at the manifest level. FS-metadata nodes are ordinary content-addressed
blocks (codec 0x02, §6) in the content-block region; the superblock’s
fs_metadata_root_digest names the root.
5.1 Canonical virtual-FS layout
Section titled “5.1 Canonical virtual-FS layout”A large, fine-grained, naively understandable, modular directory split, leveraging the Merkle structure. Canonical layout:
plugin.lttc├── manifest/ — the manifest's concern files (package/,│ │ capabilities/, dependencies/, components/,│ │ compatibility/); hashed content under the│ │ plugin root — the flat §4 region stays the│ │ single fast-path document│ └── …├── code/│ ├── wasm/│ │ ├── components/ — <name>/component.wasm│ │ ├── modules/ — ui / network / storage│ │ └── adapters/ — wasi / host / platform│ └── native/ — per-OS/arch/variant dylibs│ └── <os>/<arch>/{baseline,avx2}/…├── resources/ — assets / fonts / shaders / binaries├── localization/ — per-language strings/plurals/metadata├── themes/ — dark / light / shared (tokens, icons, assets)├── metadata/ — license / sbom / provenance / signatures└── data/ — defaults / schemas / presetsKey properties:
code/native/is a first-class part of the format — Lattice is a universal plugin-and-manifest container, not a Fera-only tool. Native code in containers is allowed by the format. Only the Fera host opts out of it (ADR-002: Fera loads third-party code as WASM only) — that is Fera’s loading policy, not a format restriction.- Fine granularity × Merkle = the update-delta story compounds: a theme-token tweak, a one-translation fix, a component rebuild — each touches its own few leaves.
5.2 Merkle trees, node encoding, pointer table
Section titled “5.2 Merkle trees, node encoding, pointer table”Trees are strictly binary: every internal node has exactly two children. Fixed invariants, trivially verifiable, predictable proofs. Odd-leaf rule: duplicate-last — when a level has an odd number of children, the final child is hashed as both the left and the right child of its parent. Writers and verifiers agree on this forever.
Internal node encoding (fixed 72 bytes):
left_digest[32] || right_digest[32] || depth u16 ||node_kind u8 (0x01 = internal, 0x02 = leaf) || flags u8 || reserved u32 (zero)Directory entry:
name_len u16 || name_utf8 || kind u8 (0x01 = dir, 0x02 = file) ||mode u32 (unix permission bits only) || child_digest[32]File entry:
name_len u16 || name_utf8 || kind u8 || mode u32 ||uncompressed_size u64 || chunk_count u32 ||chunk_digests[chunk_count × 32]Entry rules (all frozen):
- Entries within a directory are sorted by raw name bytes ascending.
- Names are UTF-8, byte-compared, no normalization; they MUST NOT
contain
/,\, or NUL, and MUST NOT equal.or..(LttcError::MalformedFsEntry). - No mtime is stored anywhere in the format (determinism beats fidelity — two builds of the same tree MUST produce the same CIDs).
- Hardlinks are implicit: two names may reference the same chunk list.
- Empty directory = a dir node with zero entries.
- Symlinks — not supported in v1 (revisit if a real use case shows up).
WASI mapping. Per-directory WASI preopens map onto capability grants. The manifest capability name → subtree path mapping (frozen v1):
| Capability | Preopened subtree |
|---|---|
translations.read |
/localization |
themes.read |
/themes |
code.execute |
/code |
A plugin granted a capability gets a preopen on the mapped subtree only;
one without it does not see that subtree at all. Capability = subtree.
Note the naming alias, stated once here: signature slot 1
“translations” covers the /localization subtree, and slot 3
“SBOM/license” covers the license region (outside the FS tree).
Multiple roots. The FS has a small set of named roots
(Plugin → /code + /data + /manifest, Translations →
/localization, Themes → /themes), each pointing at its own subtree
digest. This is what lets translations update without repacking the
wasm: a translation update changes only the localization subtree, the
corresponding root digest in the manifest, and the per-subtree
signature (if present) — the plugin subtree stays bit-identical and
keeps its original signature.
Pointer table (acceleration, optional): an index of direct jumps
into the tree, located by the superblock’s
pointer_table_offset/pointer_table_length (zero offset = absent).
- Size: 2 KiB minimum, 64 KiB maximum. Above 64 KiB the writer omits the table and the reader falls back to naive tree descent — slower and larger, accepted.
- Entry format (48 bytes, frozen):
node_digest[32] || node_offset u64 || depth u16 ||flags u16 (reserved, zero) || reserved u32 (zero)64 KiB / 48 B ≈ 1365 entries, covering trees of millions of leaves with margin.
- Trust rule (MUST): the pointer table is an acceleration hint only.
On a hit the reader MUST read the node at
node_offset, re-hash it, and require equality withnode_digestbefore trusting the jump. A table that redirects descent to a node that does not hash tonode_digestis a hard error (LttcError::HashMismatch), never a fallback. (This closes the confused-deputy hole where a malicious table points the walk at a hostile node.) - Fallback rule: the writer decides whether to emit the table (present iff it fits the cap); a reader that sees a present table MAY always ignore it and descend naively.
5.3 Super-node (root digest) construction — frozen
Section titled “5.3 Super-node (root digest) construction — frozen”The container’s root digest is a keyed BLAKE3 hash over the named subtree roots in fixed canonical order. Absent roots are omitted; the name tag makes omission unambiguous (a permuted or renamed root set hashes differently).
super_node = BLAKE3_keyed(context = "lttc-supernode-v1") over theconcatenation, for each present root in the fixed v1 order[plugin, translations, themes], of: name_len u16 || name_utf8 || digest[32] (raw 32-byte digest)The FS-metadata root digest, the license region, and the detached-sig pointer are not inputs to the super-node; they are covered transitively because they live inside the signed superblock prefix (§3). State this once: the super-node binds the content roots; the signed prefix binds the locators.
5.4 Signatures — frozen
Section titled “5.4 Signatures — frozen”- Algorithm: Ed25519 only in v1 (
sig_algregistry: 0x01 = Ed25519; BLAKE3 stays the hash). One algorithm, no negotiation. - key_id =
BLAKE3("lttc-key-v1" || ed25519_pubkey)— 32 bytes. - Signed message for slot N:
sig_input = BLAKE3-keyed(context = "lttc-sig-v1") over: "lttc-slot" || slot_index u8 || subtree_name_utf8 || superblock[0..160] || super_node_digest[32]Binding the slot index and subtree name gives replay protection
between subtrees; binding the signed prefix commits every locator
(manifest, block table, license, pointer table, content offset,
total size, feature flags, version) to the signature. The signature
value is the 64-byte Ed25519 signature over sig_input.
Detached signature mode. When HAS_DETACHED_SIGNATURE is set, a
signature lives outside the hashed content at
detached_signature_offset/length. Blob encoding (frozen):
magic "LTD1" (4 B) || sig_alg u8 (0x01) || slot_index u8 ||reserved u16 (zero) || root_digest[32] (the §5.3 super-node) ||superblock_prefix_hash[32] (BLAKE3 of signed_prefix) ||key_id[32] || signature[64]Reader rule: recompute root_digest and superblock_prefix_hash
locally and require equality before verifying the signature; a
mismatch is a hard error, not a fallback. This binds the detached
signature to this container — a valid detached signature from a
different container cannot be replayed.
Inline vs detached. The inline slots and the detached blob are alternative carriers of the same logical signatures. If both are present and disagree, §10.1 applies.
6. Merkle DAG, CIDs, and the block table
Section titled “6. Merkle DAG, CIDs, and the block table”Every piece of content (file chunk, FS-metadata node, manifest-as-leaf) is hashed. Parent nodes list child digests; the super-node (§5.3) is what the container signature actually signs.
Hash algorithm: BLAKE3 (native Merkle tree hash; tree mode is part
of its spec, not bolted on). Accepted consequence: a second hash family
in the trust stack alongside ADR-021’s SHA-256 distribution manifest.
The container’s hash is selectable per container via the superblock’s
hash_alg byte (§3) — 0x01 = SHA-256, 0x02 = BLAKE3 (default for new
containers).
6.1 CID model — frozen
Section titled “6.1 CID model — frozen”On disk, a CID is exactly the bare 32-byte digest. Every CID field in the superblock, the block table, the pointer table, and FS-metadata entries stores 32 bytes.
The tagged form — version u8 || hash_alg u8 || codec u8 || digest[32]
— exists only in memory (the Cid type). The tags are recovered
from context, not stored per reference:
hash_algcomes from the superblock’s globalhash_algbyte;codecis a property of the referencing context (block-table entry flags, pointer-table entry, root slot), not of the stored 32 bytes.
This keeps every on-disk reference at 32 bytes while preserving per-container hash selectability. Codec tag assignments (registry): 0x01 = raw content block, 0x02 = FS-metadata node, 0x03 = manifest leaf, 0x04 = license/SBOM block, 0x05 = Merkle internal node.
6.2 Block table — frozen
Section titled “6.2 Block table — frozen”Plain CARv1 is a linear block dump with no index; Lattice requires
random access. The block table is a flat sorted array of fixed-width
entries, located by the superblock’s block_table_offset /
block_table_length (both inside the signed prefix — the table is
covered by the signature, not separately content-addressed).
Entry (64 bytes, frozen, no padding):
digest[32] || offset u64 || length_uncompressed u64 ||length_on_disk u64 || flags u32 || reserved u32 (zero)- Sort key: raw 32-byte digest, ascending, unsigned byte order.
Writers MUST emit sorted and duplicate-free. Readers MUST validate
sort order and uniqueness in one O(n) pass on load and hard-error on
violation (
LttcError::MalformedBlockTable); binary search is bounds-checked. - flags u32: bit 0 =
compressed(deflate, §7); bit 1 =alignment_preserved(block contents require 4 KiB alignment, e.g. SIMD wasm blobs); bits 2–31 reserved, must be zero in writers, must-error in readers. - For uncompressed blocks
length_uncompressedMUST equallength_on_disk.
A loader can pread the whole table once and then do all subsequent
block lookups in memory.
7. Compression
Section titled “7. Compression”Deflate, per-block. Frame = block boundary.
- Deflate (not zstd): accept the worse ratio for ubiquity and implementation simplicity. Every language has a deflate; zstd is one more thing to vet and pin.
- Per-block (not whole-file, not whole-region): this is what keeps random access alive. A reader inflates only the block it actually needs; a single corrupted block does not invalidate the stream after it.
- Block boundary = compression frame boundary. No cross-block back-references. Deduplication happens at the content-addressing layer instead.
The superblock, the manifest region, and the license/SBOM region are never compressed (§4).
Decompression cap (frozen): a reader MUST refuse to inflate beyond
the block’s declared length_uncompressed and MUST hard-error
(LttcError::BlockTooLarge) if actual inflate output exceeds it — this
is the zip-bomb guard (§11).
8. Content-defined chunking — frozen
Section titled “8. Content-defined chunking — frozen”Chunking is FastCDC v2020 (the variant the fastcdc crate exposes
as fastcdc::ronomon::FastCDC). These constants are part of the
format — changing them is a major version bump:
min = 8 KiB,avg = 16 KiB,max = 64 KiB;- gear table = the v2020 reference table, reproduced in Appendix B (256 × u32, version-locked — copied into the spec, not imported by reference);
- normalization = the standard v2020 mask rule (
mask(bits + 1)strict before the pivot,mask(bits - 1)eager after); - files smaller than
minare emitted as a single block, no special inline encoding.
Because the table and parameters are pinned, two conformant writers produce identical chunkings and therefore identical CIDs — this is what makes cross-tool dedup and reproducible builds work.
9. Headroom features
Section titled “9. Headroom features”Approved for inclusion from day one; byte-level detail is in the referenced sections.
- Version + feature flags in superblock. §3.1/§3.2. Old loaders refuse politely on unknown must-understand bits.
- Multi-root subtrees. §5. Plugin / translations / themes are independent roots; translations update without repacking wasm.
- Uncompressed license/provenance (SBOM) section. §10.2. Optional region, directly pointed, stored uncompressed so legal/compliance tooling can extract licenses without a Lattice-aware decompressor.
- Delta-friendly layout. Writers SHOULD order blocks so that unchanged content keeps stable offsets across versions (OSTree/casync-style). Not a hard format requirement — a writer-side discipline that makes binary diff and HTTP-range-based updates cheap. Combined with content-defined chunking (§8) this is what makes v1 → v2 patches small.
- Optional per-subtree signatures. §5.4. Different keys for different roots — the code subtree signed by the plugin author, the translations subtree by the localization team.
- 4 KiB block alignment. Every content block starts at a 4 KiB
boundary. Costs a few KiB of padding per container, buys clean
mmapof the whole file and lets the OS page in blocks on demand. - Optional detached signature mode. §5.4. Re-signing does not re-hash the content — useful when a distribution channel re-signs without rebuilding the bundle.
10. Frozen policies and regions
Section titled “10. Frozen policies and regions”10.1 Signature-disagreement policy — frozen
Section titled “10.1 Signature-disagreement policy — frozen”When the superblock signature and the ADR-021 distribution-manifest signature for the same root CID disagree, the more restrictive outcome wins. Concretely:
- “Disagree” = both signatures verify cryptographically but over different root digests, OR exactly one of the two expected signatures is present.
- Loader behavior: hard refusal (fail closed). The error
(
LttcError::SignatureDisagreement) names both key_ids and both root digests. - If only the ADR-021 channel signature exists and the container is unsigned, that is not disagreement — it is an unsigned container, handled by host policy (Fera: refuse; other hosts: their choice).
10.2 License / SBOM region — frozen
Section titled “10.2 License / SBOM region — frozen”Optional region, gated by HAS_LICENSE_REGION, located by
license_region_offset / license_region_length (direct pointers, like
the manifest — not a CID indirection, so compliance tooling needs no
Lattice machinery).
- One uncompressed UTF-8 blob.
- v1 schema: SPDX 2.3 tag-value (plain text, greppable, zero-dependency to parse). CycloneDX may be added as an alternative via a future feature bit.
11. Limits and hardening (frozen)
Section titled “11. Limits and hardening (frozen)”A reader MUST enforce these caps; a writer MUST stay within them. Exceeding a cap is a hard error.
| Limit | Value | Error |
|---|---|---|
| Manifest region | ≤ 16 MiB | LttcError::ManifestTooLarge |
Per-block length_uncompressed |
≤ 64 MiB | LttcError::BlockTooLarge |
| Block count | ≤ 2²⁴ | LttcError::TooManyBlocks |
| Merkle depth | ≤ 64 | LttcError::TreeTooDeep |
| FS path length | ≤ 4096 bytes | LttcError::PathTooLong |
total_file_sizeMUST equal the actual file size (parse step 7, §3.2).- 4 KiB alignment padding is always zero-filled and is not part
of any block’s hashed bytes (
length_on_diskexcludes padding). - Blocks MAY span multiple 4 KiB slots (only the start is aligned).
- Inflate output exceeding declared
length_uncompressed→LttcError::BlockTooLarge(zip-bomb guard, §7).
12. Error taxonomy (spec contract) — frozen
Section titled “12. Error taxonomy (spec contract) — frozen”The lttc-format error type is part of the spec. Readers MUST be able
to raise each of these; the CLI maps each to a stable exit code ≥ 1.
enum LttcError { BadMagic, UnsupportedMajor(u32), UnsupportedMinor(u32), UnknownFeatureBit(u8), // lowest unknown bit; message lists all NonZeroReserved { offset: u64 }, SizeMismatch, RegionOverlap, MalformedBlockTable, MalformedFsEntry, HashMismatch { digest: [u8; 32] }, SignatureInvalid { slot: u8 }, SignatureDisagreement { key_ids: [[u8; 32]; 2], digests: [[u8; 32]; 2] }, ManifestTooLarge, BlockTooLarge, TooManyBlocks, TreeTooDeep, PathTooLong,}13. Tooling (day one)
Section titled “13. Tooling (day one)”Lattice ships with:
-
Rust build-script integration. A
build.rs-consumed crate that packs a plugin from a cargo workspace into.lttc. Targetswasmtimeout of the box — the produced bundle is directly loadable by a wasmtime-based host without further translation. -
CLI. Verbs:
lttc pack <dir> -o plugin.lttc— create a bundle. MUST be deterministic: identical input tree + key + spec-pinned parameters ⇒ byte-identical output.lttc verify <plugin.lttc>— verify superblock, manifest, Merkle root, signatures; exit 0 on success, else exit non-zero with the §12 error named.lttc unpack <plugin.lttc> <dir>— extract. Round-trip guarantee:unpack→packMUST reproduce the original root digest (CI gate).lttc inspect,lttc diff— block-level, useful for delta-update debugging.
-
Config duality. Config is RON inside the bundle, TOML outside. The CLI accepts TOML on the way in (human-edited), emits canonical RON into the bundle (§4.1), and converts back to TOML on
inspect/unpack. Two formats are deliberate.
14. Resolved decisions
Section titled “14. Resolved decisions”All four first-round OPENs decided 2026-09-30; the Draft-v1 freeze items
decided 2026-10-08. Brief rationale per decision (the full
alternatives-considered discussion lives in OPEN-DECISIONS.md history):
- OPEN-1 → BLAKE3. Native Merkle tree hash, faster in software.
Accepted: second hash family in the trust stack (ADR-021 manifest
stays SHA-256); the
hash_algsuperblock byte keeps it per-container selectable, so not a permanent binding. - OPEN-2 → FastCDC. Chunk boundaries follow content — a one-byte insertion shifts nothing outside its own chunk. Best dedup across versions and shared assets; with §9’s delta-friendly layout this is what delivers small v1 → v2 patches. Accepted: rolling-hash implementation in the writer, more Merkle nodes.
- OPEN-3 → Both signatures. Superblock signature for self-containment + ADR-021 distribution-manifest signature (by root CID) for the channel. The disagreement policy is §10.1 (frozen 2026-10-08: restrictive-wins, fail closed).
- OPEN-4 → Little-endian. Matches every target platform shipped on. Full byte layout frozen in this Draft v1.
- Magic
b"LTTC_BUNDLE >^<\0"— 16 bytes exactly (one aligned[u8; 16]read; the earlierLTTC.BUNDLE>.<\0candidate was 15 and dropped for exactly that reason). - Superblock 4 KiB with
header_size— one aligned page read; a future revision may change the size while an old reader still finds where its known fields end. - 4 signature slots — 0 code, 1 translations, 2 themes, 3 SBOM.
- Binary Merkle trees + pointer table (48 B entries, 2–64 KiB, naive descent above) — fixed invariants, trivially verifiable.
- Manifest = single flat region (Draft v1) — the manifest-as-subtree model from Draft v0.1 was dropped; the flat region is authenticated via the signed superblock prefix (§4).
- Reserved tail 3536 B (560..4096), zero-check scoped to known minor.
Appendix A: Pronunciation and naming
Section titled “Appendix A: Pronunciation and naming”- Lattice — the format and the project.
- Lattice Container / Lattice Package Format — long forms.
- LTT Container — short form in speech and code identifiers.
.lttc— file extension, pronounced L-T-T-C /ˌɛl tiː tiː siː/.
See NAMING-UX.md §1 for the rationale.
Appendix B: FastCDC v2020 gear table (frozen)
Section titled “Appendix B: FastCDC v2020 gear table (frozen)”256 × u32. This is the fastcdc::ronomon reference table; it is copied
into the spec so the format is version-locked and does not import the
table by reference. Generated by ciphering a 1024-byte array of zeros
with a 32-byte zero key and 16-byte zero nonce; the high bit of each
value is cleared (31-bit integers avoid signed-overflow in the rolling
hash).
#[rustfmt::skip]const GEAR: [u32; 256] = [ 0x5c95c078, 0x22408989, 0x2d48a214, 0x12842087, 0x530f8afb, 0x474536b9, 0x2963b4f1, 0x44cb738b, 0x4ea7403d, 0x4d606b6e, 0x074ec5d3, 0x3af39d18, 0x726003ca, 0x37a62a74, 0x51a2f58e, 0x7506358e, 0x5d4ab128, 0x4d4ae17b, 0x41e85924, 0x470c36f7, 0x4741cbe1, 0x01bb7f30, 0x617c1de3, 0x2b0c3a1f, 0x50c48f73, 0x21a82d37, 0x6095ace0, 0x419167a0, 0x3caf49b0, 0x40cea62d, 0x66bc1c66, 0x545e1dad, 0x2bfa77cd, 0x6e85da24, 0x5fb0bdc5, 0x652cfc29, 0x3a0ae1ab, 0x2837e0f3, 0x6387b70e, 0x13176012, 0x4362c2bb, 0x66d8f4b1, 0x37fce834, 0x2c9cd386, 0x21144296, 0x627268a8, 0x650df537, 0x2805d579, 0x3b21ebbd, 0x7357ed34, 0x3f58b583, 0x7150ddca, 0x7362225e, 0x620a6070, 0x2c5ef529, 0x7b522466, 0x768b78c0, 0x4b54e51e, 0x75fa07e5, 0x06a35fc6, 0x30b71024, 0x1c8626e1, 0x296ad578, 0x28d7be2e, 0x1490a05a, 0x7cee43bd, 0x698b56e3, 0x09dc0126, 0x4ed6df6e, 0x02c1bfc7, 0x2a59ad53, 0x29c0e434, 0x7d6c5278, 0x507940a7, 0x5ef6ba93, 0x68b6af1e, 0x46537276, 0x611bc766, 0x155c587d, 0x301ba847, 0x2cc9dda7, 0x0a438e2c, 0x0a69d514, 0x744c72d3, 0x4f326b9b, 0x7ef34286, 0x4a0ef8a7, 0x6ae06ebe, 0x669c5372, 0x12402dcb, 0x5feae99d, 0x76c7f4a7, 0x6abdb79c, 0x0dfaa038, 0x20e2282c, 0x730ed48b, 0x069dac2f, 0x168ecf3e, 0x2610e61f, 0x2c512c8e, 0x15fb8c06, 0x5e62bc76, 0x69555135, 0x0adb864c, 0x4268f914, 0x349ab3aa, 0x20edfdb2, 0x51727981, 0x37b4b3d8, 0x5dd17522, 0x6b2cbfe4, 0x5c47cf9f, 0x30fa1ccd, 0x23dedb56, 0x13d1f50a, 0x64eddee7, 0x0820b0f7, 0x46e07308, 0x1e2d1dfd, 0x17b06c32, 0x250036d8, 0x284dbf34, 0x68292ee0, 0x362ec87c, 0x087cb1eb, 0x76b46720, 0x104130db, 0x71966387, 0x482dc43f, 0x2388ef25, 0x524144e1, 0x44bd834e, 0x448e7da3, 0x3fa6eaf9, 0x3cda215c, 0x3a500cf3, 0x395cb432, 0x5195129f, 0x43945f87, 0x51862ca4, 0x56ea8ff1, 0x201034dc, 0x4d328ff5, 0x7d73a909, 0x6234d379, 0x64cfbf9c, 0x36f6589a, 0x0a2ce98a, 0x5fe4d971, 0x03bc15c5, 0x44021d33, 0x16c1932b, 0x37503614, 0x1acaf69d, 0x3f03b779, 0x49e61a03, 0x1f52d7ea, 0x1c6ddd5c, 0x062218ce, 0x07e7a11a, 0x1905757a, 0x7ce00a53, 0x49f44f29, 0x4bcc70b5, 0x39feea55, 0x5242cee8, 0x3ce56b85, 0x00b81672, 0x46beeccc, 0x3ca0ad56, 0x2396cee8, 0x78547f40, 0x6b08089b, 0x66a56751, 0x781e7e46, 0x1e2cf856, 0x3bc13591, 0x494a4202, 0x520494d7, 0x2d87459a, 0x757555b6, 0x42284cc1, 0x1f478507, 0x75c95dff, 0x35ff8dd7, 0x4e4757ed, 0x2e11f88c, 0x5e1b5048, 0x420e6699, 0x226b0695, 0x4d1679b4, 0x5a22646f, 0x161d1131, 0x125c68d9, 0x1313e32e, 0x4aa85724, 0x21dc7ec1, 0x4ffa29fe, 0x72968382, 0x1ca8eef3, 0x3f3b1c28, 0x39c2fb6c, 0x6d76493f, 0x7a22a62e, 0x789b1c2a, 0x16e0cb53, 0x7deceeeb, 0x0dc7e1c6, 0x5c75bf3d, 0x52218333, 0x106de4d6, 0x7dc64422, 0x65590ff4, 0x2c02ec30, 0x64a9ac67, 0x59cab2e9, 0x4a21d2f3, 0x0f616e57, 0x23b54ee8, 0x02730aaa, 0x2f3c634d, 0x7117fc6c, 0x01ac6f05, 0x5a9ed20c, 0x158c4e2a, 0x42b699f0, 0x0c7c14b3, 0x02bd9641, 0x15ad56fc, 0x1c722f60, 0x7da1af91, 0x23e0dbcb, 0x0e93e12b, 0x64b2791d, 0x440d2476, 0x588ea8dd, 0x4665a658, 0x7446c418, 0x1877a774, 0x5626407e, 0x7f63bd46, 0x32d2dbd8, 0x3c790f4a, 0x772b7239, 0x6f8b2826, 0x677ff609, 0x0dc82c11, 0x23ffe354, 0x2eac53a6, 0x16139e09, 0x0afd0dbc, 0x2a4d4237, 0x56a368c7, 0x234325e4, 0x2dce9187, 0x32e8ea7e,];Rolling-hash reference (normative): rolling hash u32; for each byte
b, hash = (hash >> 1) + GEAR[b]. Cut point when hash & mask == 0;
mask = mask(bits+1) before the pivot, mask(bits-1) after, where
bits = round(log2(avg)) and mask(n) = 2^n - 1. Pivot =
center_size(avg, min, remaining).