Status: record of the design as it stands on 2026-09-03. Update this file when
an operation, its envelope, its transport or a cap changes; the worknote index,
the lean4-wasm-export skill and the shardborough-storage skill link here.
The WASM module has no file system. It is built with Emscripten without libuv,
Wasm/l4_stubs.c stubs exactly one symbol (initialize_libuv), and nothing
reachable from the exported ABI touches Lean's IO, socket or task layers — that
smallness is the standing purity evidence of skills/lean4-wasm-export. So a
JavaScript host must do the file reads.
Iron rule 7 of CLAUDE.md forbids hand-written JavaScript that reimplements
what the formal source defines. A host that parsed manifest.sbm2, chose
blocks for a query, checked a SHA-256 or decoded an IBK3 row would be a second
implementation of the storage format in a language with no proofs. The
operations below exist so it does none of that: it reads files by name and
carries bytes.
All three are ordinary dispatch ops (Wasm/Dispatch.lean), so they ride the
existing l4_call / l4_call_io exports. storeQuery also has a byte region,
which needs the new l4_call_blob export of section 4.
storeManifestInspect(manifestHex)#Decodes one manifest with ShardManifest.decode? and reports what the host
needs in order to fetch artifacts: wire version, layout label, blank-node
publication profile, term-registry version, whether the manifest carries a
fixed-chunk Merkle commitment, byte and row totals, and one record per entry —
artifact key, byte length, SHA-256 hexadecimal, chunk bytes, chunk count,
Merkle root, predicate IRI, row count, block kind (IBK3 or IBK4),
blank-node scope, the graph-name set with the default graph marked
({"kind":"default"}), and the sidecar keys that are present.
Nothing here is read from a block. Every value is committed by the manifest
bytes themselves. Bytes that do not decode are refused with the shared error
envelope; decode? returns none for anything ShardManifest.valid refuses,
so an inadmissible manifest never reaches the host as a half-answer.
storeQueryPlan(manifestHex, sparql)#Reports the artifact keys the host must fetch, in manifest order, the open mode the native tools print for the same decision, and the byte and row totals those artifacts declare.
The selection is the collectors the native tools already use, not a second planner:
| Generation | Collector | Mode string |
|---|---|---|
| IBK3 layouts, constant predicates | ShardManifest.queryNativeConstantPredicates? then entriesForPredicates |
ibk3-paged-merkle(N) |
| IBK3 layouts, no constant-predicate plan | every entry | ibk3-paged-merkle-full-manifest(N) |
SBM7 / quad-ibk4-ptd1-merkle-v0 |
ShardManifest.quadEntriesForQuery |
ibk4-full-manifest(N) |
N is the distinct predicate count for the IBK3 modes and the entry count for
the IBK4 mode, which is what l4block-id-v3-query and l4block-quad-query
print in their open-mode=NAME(n) headers.
The section 18.6 EXISTS guard is carried by the collectors and is not restated
here. queryNativeConstantPredicates? and queryQuadConstantPredicates? both
answer none unless Query.expressionsOutsidePatternExistsFree, and a none
selects every entry — an EXISTS in a projection, a GROUP BY key, a HAVING
condition or an ORDER BY condition is evaluated against the active graph, so
the query must see the whole generation.
The plan applies no cap. A host uses it to decide what to fetch, and the caps belong to the operation that reads bytes.
storeQuery(manifestHex, sparql, artifactsJson) plus one blob region#Evaluates the query and answers the ordinary queryDataset envelope with
"shards" and "mode" placed before "kind", so a host handles the result
exactly as it handles queryDataset.
What it checks before it answers:
rangeCommitted) —
every layout the packer writes does.Crypto.sha256,
the specification hash of section 6.3 of docs/shardborough-storage-spec.md.rows — the same admission
Harness/QuadQuery.lean and Harness/IndexedBlockV3Materialize.lean apply
natively.The chunk Merkle ROOT is deliberately not recomputed. This operation reads whole artifacts, so the full-artifact digest already covers every byte, and a second pass would double the hashing cost. A host that fetches RANGES needs the Merkle path and is a different operation.
An IBK3 generation gives a graph (the default graph); an IBK4 generation gives
a dataset through Storage/QuadDataset.datasetOfQuads, graph column none
being the default graph. An IBK4 dataset carrying a blank-node graph name takes
the reference evaluator, because SPARQL/StoreDataset.materialiseDatasetBackend
keeps only IRI-named graphs — the same rule the native quad tool applies.
A cap trip is an explicit error naming the cap and the value that exceeded it. Nothing is truncated (anti-pattern 25).
| Cap | Value | Checked against |
|---|---|---|
| selected artifacts | 64 | the plan's entry count |
| total artifact bytes | 8388608 (8 MiB) | the manifest's declared extents, then again the supplied window lengths |
| total rows | 100000 | the manifest's declared row counts |
All three are checked from the manifest before a single byte is hashed. 8 MiB
is the byte budget queryIBK3BlockSetPreview already carries, and it is set by
the cost of the pure Lean SHA-256 rather than by memory: the hash is the
dominant term in a whole-artifact admission.
The dispatch ABI carries strings. Hexadecimal doubles every byte across the
boundary and then costs a character walk on the way in — the class of cost the
decoder work of 2026-09-03 was removing, and base64 only reduces the first term
to 1.33x while keeping the second. So storeQuery does not use either.
The host allocates ONE buffer inside the wasm heap (_malloc, already
exported), writes every artifact into it back to back with no encoding at all,
and calls the new l4_call_blob_c:
char *l4_call_blob_c(const char *op, const char *args_json,
const uint8_t *blob, size_t blob_len);
The shim builds a Lean ByteArray from that region with one
lean_alloc_sarray plus one memcpy and calls the Lean export
l4_call_blob : String -> String -> ByteArray -> String. It moves bytes and
never interprets them: it holds no knowledge of any block, manifest or digest
format, and a change that gives it any belongs in Lean instead.
Which bytes belong to which artifact is said in artifactsJson:
[{"key": "predicate-0.ibk3", "offset": 0, "len": 118769}]
Two properties follow, and they are why this shape was chosen over a pointer/length pair per artifact or a blob-handle table:
ByteArray Lean owns, so an offset past the end is an ordinary refusal
(storeQuery: artifact 'k' names blob bytes [a, b) but the call carried n blob bytes), never a memory fault, and a stale pointer cannot be expressed.storeQuery also accepts {"key": …, "bytes": "<hex>"}. That form exists so
the operation still answers through the plain l4_call entry, which carries no
region — diagnostics and small fixtures. A host must not use it. Exactly one of
the two forms may appear per artifact.
storeManifestInspect and storeQueryPlan take the manifest as hexadecimal.
A manifest is a few kilobytes, and one encoding for the small argument keeps
the three signatures uniform.
Largest sequence_variant IBK3 block, 118,769 bytes; native l4wasm-cli,
whole operation including process start and Lean initialisation; mean of 10
runs, repeated twice:
| Path | Time | Args document |
|---|---|---|
hexadecimal "bytes" |
96 ms, 97 ms | 242,416 bytes |
| blob region + windows | 71 ms, 70 ms | 4,893 bytes |
So the hexadecimal transport costs about a quarter of the whole operation for one 116 KiB block, and its argument document is 49.5 times larger. The gap widens on wasm32, where the character walk is slower than it is natively.
Harness/IndexedBlockV3Query.lean need
range reads and are not offered here.| Path | Role |
|---|---|
formal/lean4/Wasm/Ops/Store.lean |
the three operations |
formal/lean4/L4Factoidal/Storage/ShardManifest.lean |
isIbk3Layout, isIbk4Layout, predicateOrder, the collectors |
formal/lean4/L4Factoidal/Storage/QuadDataset.lean |
the dataset an IBK4 quad sequence denotes, shared with Harness/QuadQuery.lean |
formal/lean4/Wasm/Dispatch.lean |
blobOpNames, callBlob |
formal/lean4/Wasm/Exports.lean |
@[export l4_call_blob] |
formal/lean4/Wasm/l4_shim.c |
l4_call_blob_c |
docs/web/hub/assets/l4/l4factoidal.js |
l4.callBlob(op, args, bytes) |
formal/lean4/Wasm/Main.lean |
l4wasm-cli callblob <op> <argsJsonFile> <blobFile> |
npm/factoidal/bin/store.mjs |
the shipped host: open, plan, read, one blob, render |
npm/factoidal/bin/factoidal.mjs |
factoidal inspect and factoidal query |
factoidal inspect prints what storeManifestInspect decoded;
--json prints that envelope unchanged. factoidal query runs the
sequence in one process: storeQueryPlan, then storeQuery with an
EMPTY descriptor list to get the cap decision before a byte is read
(the caps are checked before the descriptors are), then the artifact
reads, then storeQuery over one _malloc buffer with a
{"key","offset","len"} window per artifact. The host holds none of
the cap values; it asks.
Two runtime characteristics the command surfaces rather than hides, both measured 2026-09-03:
SELECT ?s ?p ?o WHERE { ?s ?p ?o } exhausts Node's default
WebAssembly frame budget, while SELECT *, or the same query with a
LIMIT, does not, and Deno clears all of them.
node --stack-size=4000 clears it. The command reports it and exits
1.SELECT (COUNT(*) AS ?n) over the 6455-triple
sequence_variant store: 220 ms through the command, 33 ms through
l4block-id-v3-query (macOS arm64, three alternating runs each).| Gate | What it covers |
|---|---|
bash formal/lean4/Wasm/native-smoke.sh |
every operation, both transports, the digest refusal, the window-overrun refusal, the artifact-cap refusal, and the blobOps reflection — through the native CLI, so an ABI fault and a wasm-toolchain fault stay distinguishable |
bash tools/wasm-store-query-smoke.sh |
the committed wasm module against generations the Lean packer just wrote, comparing rows with l4block-id-v3-query and l4block-quad-query |
bash tools/blockengine-ibk4-quad-smoke.sh |
the native IBK4 path the wasm operations are compared against |
node tests/store-host/cli.mjs |
the shipped factoidal command against l4block-id-v3-query and l4block-quad-query, comparing the ROWS and not only their count, under Node and under Deno |