Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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 (решётка).


The format exists to give a WASM plugin package these properties:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. mmap-friendly. Blocks are aligned on 4 KiB boundaries so a loader can mmap the container and let the OS manage residency, instead of copying the whole image into an in-memory buffer.

  6. 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.

  7. 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.

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.


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).

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.

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:

  1. Magic — LttcError::BadMagic.
  2. header_size (≥ the reader’s known field span, ≤ file size).
  3. format_version_major — LttcError::UnsupportedMajor.
  4. format_version_minor — LttcError::UnsupportedMinor if higher than the reader implements.
  5. feature_flags against the reader’s known-bit mask — LttcError::UnknownFeatureBit(lowest unknown bit); the error message lists all unknown bits.
  6. Reserved-tail zero check, scoped to the known minor (§3) — LttcError::NonZeroReserved { offset }.
  7. total_file_size MUST equal the actual file size — LttcError::SizeMismatch.
  8. Structural bounds: every offset + length pair lies within the file; regions do not overlap — LttcError::RegionOverlap.
  9. Signature verification (§5.4).
  10. Lazy per-block verification on access (§6).

Steps 1–8 are pure arithmetic on the superblock and complete before any hash or signature work.


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 / grep against the raw .lttc file. 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 one pread after 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_length live inside signed_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 the plugin root’s /manifest/ directory and hashed there). A reader MUST verify at least one signature before trusting manifest contents; the fast path is one pread + 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.

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:

  • PrettyConfig with structural_names = true, depth_limit = 64;
  • no extensions (in particular implicit_some disabled);
  • fields emitted in declared struct order (never map-iteration order);
  • maps, where they appear, sorted by raw key bytes ascending;
  • UTF-8, no BOM, \n line endings, single trailing newline.

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, and MULTI_ROOT (§3.1) is set iff roots.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.


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.

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 / presets

Key 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 with node_digest before trusting the jump. A table that redirects descent to a node that does not hash to node_digest is 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 the
concatenation, 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.

  • Algorithm: Ed25519 only in v1 (sig_alg registry: 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.


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).

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_alg comes from the superblock’s global hash_alg byte;
  • codec is 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.

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_uncompressed MUST equal length_on_disk.

A loader can pread the whole table once and then do all subsequent block lookups in memory.


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).


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 min are 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.


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 mmap of 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.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).

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.

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_size MUST 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_disk excludes 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,
}

Lattice ships with:

  1. Rust build-script integration. A build.rs-consumed crate that packs a plugin from a cargo workspace into .lttc. Targets wasmtime out of the box — the produced bundle is directly loadable by a wasmtime-based host without further translation.

  2. 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 → pack MUST reproduce the original root digest (CI gate).
    • lttc inspect, lttc diff — block-level, useful for delta-update debugging.
  3. 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.


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_alg superblock 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 earlier LTTC.BUNDLE>.<\0 candidate 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.

  • 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).