···1515| strict | ❌ | ✅ | ✅ | ✅ |
1616| deterministic | ❌ | ✅ | ✅ | ✅ |
1717| slices, sparse | ✅ | ❌^4 | ✅ | ✅ |
1818-| subtree | ❌ | ✅^5 | ✅ | ✅ |
1818+| subtree | ❌ | ✅ | ✅ | ✅ |
191920202121Read more:
···44443. STAR-lite values can be emitted immediately and trivially from its encoded form with zero buffering required. However, MST recovery (or pre-verification) requires either two passes or disk spilling -- but it's still more efficient than CAR.
454546464. STAR-lite *could* support MST slices and probably sparse MSTs, but this is not specified yet. MST slices in particular would be valuable.
4747-4848-5. Work in progress (easy)
4949-5050-5151-524753485449
+144-16
star-lite/readme.md
···3333STAR-lite is just a flat list of every key/record pair in the repository, in lexicographic key order, with a commit object in its header.
34343535```
3636-|------ header ------| |------------------ data (records) -------------------|
3737-[ magic | len | cbor ] [ len | str | len | cbor ] [ len | str | len | cbor] …
3636+|--------- header ---------| |------------------ data (records) -------------------|
3737+[ magic | cid | len | cbor ] [ len | str | len | cbor ] [ len | str | len | cbor] …
3838```
39394040| name | type |
4141| ----- | -------------------------------------- |
4242| magic | three-byte mark to identify the format |
4343+| cid | multiformats sha256 CID link |
4344| len | unsigned varint |
4445| str | utf-8 bytes |
4546| cbor | cbor bytes |
464747484848-### Magic
4949+### Header magic
49505051Three bytes: `0x2A 0x6C 0x00`, ASCII for `*l\0`: "star", "**l**ite", version 0.
515252535353-### Header len + cbor: Commit
5454+### Header CID
54555555-A length-prefixed CBOR blob containing an atproto signed Commit object. The CBOR format is the same as the atproto repo spec describes. The Commit may be ignored, but for archive content verification, its `data` field must be parsed at minimum.
5656+A 36-byte CID link to the root of the repo MST, ie., the `data` field from the repo's current [`Commit` object][commit].
56575757-A parser may reject a commit if its `varint` length is greater than 4KiB (TODO: we could make this really tight, but 2KiB for the did:web and then 2K for the rest should prevent large reads and not be limiting)
5858+Archive integrity can be verified by recovering the MST from its contents and computing the CID of the root MST node, which must match.
58595959-TODO: like the other STAR formats we should actually define a slightly-modified commit object, specifically with a nullable data cid for empty repos. otherwise, we should include the magic CID of an empty atproto MST node's hash like you get with CARs.
6060+6161+### Header len + cbor: optional partial commit object
6262+6363+When `len == 0`, no commit object is included in the archive. This is useful for archiving unsigned subtrees of a full repository tree -- the contents can still be verified from the preceeding CID field.
6464+6565+When `len > 4096`, a parser may reject the commit object as being implausibly large. (TODO: we probably can set an exact limit. DID max is 2048 in atproto, rev must be TID format, etc).
6666+6767+Otherwise, when `len > 0`, a partial commit object of exactly `len` bytes follows, in CBOR format. The partial commit has the same fields as an [atproto Commit Object][commit] except that the `data` field must be omitted.
6868+6969+To verify the commit signature, use the Header CID (above) as the `data` field to compute the commit's signed CID.
607061716272### Data: keys and records
···9210293103While any atproto MST library can reconstruct a full repo MST by simply inserting each `(key, record)` pair, this usually carries high overhead (memory or i/o) for the most frequent operations on a STAR-lite file: verification, and conversion to stream-ordered CAR.
94104105105+By exploiting the strict lexicographic key ordering of STAR-lite files, we can implement these transformations directly and with lower overhead.
95106961079797-By exploiting the strict lexicographic key ordering of STAR-lite files, we can implement these transformations directly and with minimal overhead.
108108+### MST node stack
109109+110110+We don't need to materialize the entire MST at once for a depth-first tree-reconstructing walk across it: a small stack of in-progress MST nodes (one per layer of the tree) is sufficient.
111111+112112+When a key's layer is *greater than the previous* key's layer, all in-progress MST nodes from lower layers are complete, and can be **frozen**: encoded in atproto MST node format to compute their CIDs, recursively resolving into a CID link from the current key's node.
113113+114114+At this point, the newly frozen nodes can be:
115115+116116+- simply discarded, when verifying archive integrity,
117117+- serialized into runs of CAR-format blocks,
118118+- any other transformation
119119+120120+Once the entire tree has been walked and frozen, the highest-layer MST node can finally be considered frozen to produce the root node CID, which be match the CID in a STAR-lite file's header.
9812199122100123### Conversion to CAR
101124125125+For preorder traversal block ordering of CAR files (aka "stream-friendly order"), each parent MST node must be included *before* all of its children. So while subtrees can be serialized into CAR-output-ready byte sequences as soon as they are frozen, they must be buffered until their parent node is frozen
126126+127127+Since our depth-first walk finalizes children before parents, and the final parent finalizes last, we must unfortunately buffer all serialized CAR frames while the tree is walked. The good news is that a disk-spill-friendly byte log works well for this buffering.
128128+129129+130130+#### some old intuition-y words that might go somewhere but not here now
131131+102132Stream-ordered CARs (in "preorder traversal" block order) are a depth-first walk over the Merkle Search Tree, and keys encountered during a depth-first MST walk are in strict lexicographic order.
103133104134There is a a useful symmetry here:
···108138109139So, any subtree-spanning range of keys (and records) can be materialized directly into its stream-ordered sequence of CAR blocks, independent of the rest of the archive.
110140111111-MST subtrees can't be *emitted* until the entire MST has been reconstructed, because stream-ordering requires that the very first CAR block is the MST root node, and that is the very last node we can serialize.
112141113113-But what we can do, is write serialized segments of the final CAR to disk temporarily as the entire MST is reconstructed, to stay within a strict memory budget. Streaming out the final stream-ordered CAR can use `copy_file_range` or equivalent to splice them in at the right places.
142142+#### pseudo-code
114143115115-```
116116-TODO: actual algorithm pesudocode
144144+```python
145145+## WIP!
146146+147147+def to_stream_ordered_car(key_record_pairs):
148148+ stack = []
149149+ byte_log = [] # disk spilling omitted from this example
150150+ prev_key_layer = 0
151151+152152+ for (key, record) in key_record_pairs:
153153+ record_cid = compute_cid(record)
154154+155155+ record_run = byte_log.append_car_frame(record_cid, record)
156156+157157+ key_layer = layer_of(key)
158158+159159+ extend stack with empty slots until len(stack) >= key_layer + 1
160160+161161+ # every layer below key_layer that has content gets frozen. Its
162162+ # node frame is appended to the byte log, and the resulting
163163+ # subtree's emit plan is propagated up to layer L+1.
164164+ for lower_layer in range(0, key_layer):
165165+ if node := stack.get(lower_layer):
166166+ (node_cid, node_bytes) = encode_mst_node(node)
167167+ node_run = byte_log.append_car_frame(node_cid, node_bytes)
168168+ subtree_emit_plan = build_emit_plan(node, node_run)
169169+ push_subtree_with_plan(stack[lower_layer + 1], node_cid, subtree_emit_plan)
170170+ stack[lower_layer] = None
171171+172172+ # bleh, None handling kind of sucks. we should actually check nodes for .empty() and push/extend where needed
173173+174174+ if stack.get(key_layer) is None:
175175+ stack[key_layer] = make_empty_node() # blehhh
176176+177177+ stack[key_layer].entries.append(WhatIsThis(
178178+ key=key,
179179+ cid=record_cid,
180180+ car_run=record_run,
181181+ right=None,
182182+ right_emit_plan=None,
183183+ ))
184184+185185+ # End of input: fold remaining stack bottom-up the same way.
186186+ node_cid, node_emit_plan = None, None
187187+ for node in stack:
188188+ if node_cid is not None:
189189+ push_subtree_with_plan(node, node_cid, node_emit_plan)
190190+ node_cid, node_emit_plan = None, None
191191+ if node is not empty:
192192+ (node_cid, node_bytes) = encode_mst_node(node)
193193+ node_run = byte_log.append_car_frame(node_cid, node_bytes)
194194+ node_emit_plan = build_emit_plan(node, node_run)
195195+ node_cid = node_cid
196196+197197+ # Empty repo: emit the canonical empty MST node into the byte log.
198198+ if node_cid is None:
199199+ (node_cid, node_bytes) = encode_mst_node(empty stack-slot)
200200+ node_run = byte_log.append_car_frame(node_cid, node_bytes)
201201+ node_emit_plan = [node_run]
202202+203203+ output = []
204204+ for run in node_emit_plan:
205205+ output.extend(byte_log[run.what:run.whattt])
206206+207207+ return node_cid, output
117208```
118209119210···125216126217The final output is the root MST node's CID, which verifies the entire archive if it matches the `data` field from the commit object.
127218128128-TODO: mention that this doesn't check the commit's signature but users can do that on the commit object first, directly, if they want. verification just asserts that the archive content is consistent with the commit object's `data` commitment.
219219+Verification asserts the integrity of the repository contents: verifying the signature of the archive's [commit object][commit] (if present) is a separate process, outside the scope of STAR. See atproto [commit signatures][commit-sigs]
129220130221131131-```
132132-TODO: actual algorithm pseudocode
222222+```python
223223+## WIP!!
224224+225225+def verify(key_record_pairs, expected_root_cid):
226226+ stack: list[MstNode] = []
227227+228228+ for (key, record) in key_record_pairs:
229229+ record_cid = compute_cid(record)
230230+ key_layer = layer_of(key)
231231+ while len(stack) <= key_layer:
232232+ stack.append(MstNode())
233233+234234+ for i in range(key_layer):
235235+ if stack[i].is_empty():
236236+ continue
237237+ (node_cid, _) = encode_mst_node(stack[i])
238238+ stack[i + 1].attach_subtree(node_cid)
239239+ stack[i] = MstNode() # empty it
240240+241241+ stack[key_layer].entries.append(Leaf(key, record_cid, car_run=None))
242242+243243+ # Fold remaining stack bottom-up.
244244+ node_cid = None
245245+ for node in stack:
246246+ if node_cid is not None:
247247+ node.attach_subtree(node_cid)
248248+ node_cid = None
249249+ if not node.is_empty():
250250+ (node_cid, _) = encode_mst_node(node)
251251+252252+ # Empty repo: canonical empty MST node CID.
253253+ if node_cid is None:
254254+ (node_cid, _) = encode_mst_node(MstNode())
255255+256256+ return node_cid == expected_root_cid
133257```
134258135259136260#### Empty repos
137261138138-a repo with zero keys is allowed: its commit object must use the magic CID `bafyreihmh6lpqcmyus4kt4rsypvxgvnvzkmj4aqczyewol5rsf7pdzzta4` (TODO: double-check this), which corresponds to the CID of a single empty atproto MST node - how atproto CARs represent empty repos.
262262+A repo with no keys is allowed. Its header CID is always `bafyreihmh6lpqcmyus4kt4rsypvxgvnvzkmj4aqczyewol5rsf7pdzzta4`, the CID of a single empty atproto MST node.
263263+264264+Note that when generating a CAR file, the empty MST node block must be included.
139265140266141267### Conversion from CAR
···153279[leb128]: https://en.wikipedia.org/wiki/LEB128
154280[nsid]: https://atproto.com/specs/nsid
155281[rkey]: https://atproto.com/specs/record-key
282282+[commit]: https://www.ietf.org/archive/id/draft-holmgren-at-repository-00.html#section-2.4
283283+[commit-sigs]: https://www.ietf.org/archive/id/draft-holmgren-at-repository-00.html#name-commit-signatures