06. Lockfile format: mochi.lock canonical TOML serialisation
Status: research note. Date: 2026-05-29 (GMT+7).
Mirrors: deployed to /docs/research/0057/lockfile-format.
This note specifies mochi.lock. The text-format rationale is in 02-design-philosophy §3; the surveyed lockfiles are in 03-prior-art-registries.
1. Why text TOML, again
Three properties matter for a lockfile:
- Reviewability: a PR bumping a dep must produce a small, diff-friendly change so reviewers can spot smuggled deps, scope hijacks, or version regressions.
- Reproducibility: the same manifest + same registry snapshot must produce a byte-identical lockfile across machines.
- Migration friendliness: when the format evolves, old lockfiles must be readable by new tools and new lockfiles must produce comprehensible errors in old tools.
Text TOML hits all three. Binary formats (the cautionary tale: Bun's bun.lockb, reversed 2025) lose on (1) immediately. Custom text formats (Go's go.sum line format) work but reinvent serialisation; reusing TOML carries free comment support and an existing parser surface. The strongest comparable system in 2026 is uv's uv.lock (text TOML, universal across platforms); MEP-57 follows it closely.
2. Top-level structure
# This file is generated by `mochi lock`. Do not edit by hand.
version = 1
mochi = "0.7.0"
manifest = "mochi.toml"
manifest_hash = "blake3-256:<hex>"
[[platform]]
os = "linux"
arch = "x86_64"
target = "typescript"
[[platform]]
os = "macos"
arch = "aarch64"
target = "typescript"
[[platform]]
os = "linux"
arch = "x86_64"
target = "python"
[[package]]
name = "@mochi/strings"
version = "0.4.7"
source = "registry:index.mochi.dev"
blake3 = "<hex>"
sha256 = "<hex>"
yanked = false
capabilities = []
[package.dependencies]
[[package.platform]]
index = 0 # references [[platform]] above
[[package.platform]]
index = 1
[[package.platform]]
index = 2
[[package]]
name = "@mochi/json"
version = "1.2.5"
source = "registry:index.mochi.dev"
blake3 = "<hex>"
sha256 = "<hex>"
yanked = false
capabilities = ["fs.read"]
[package.dependencies]
"@mochi/strings" = "0.4.7"
[[package]]
name = "@my/parser"
version = "0.1.0"
source = "workspace"
path = "packages/parser"
[package.dependencies]
"@mochi/strings" = "0.4.7"
[capabilities_seen]
"@mochi/strings" = []
"@mochi/json" = ["fs.read"]
"@my/parser" = []
[provenance]
solver_seed = "v1-2026-05-29T06:35:00Z"
registry_etag = "<opaque ETag string>"
sigstore_verified_count = 2
sigstore_unverified = []
3. Field-by-field
3.1 Header
| Field | Type | Notes |
|---|---|---|
version | int | Lockfile schema version. v1 is the initial release. |
mochi | string | Compiler version that produced the lock. |
manifest | string | Manifest file path (relative to lock). |
manifest_hash | string | BLAKE3-256 of the manifest at lock time. |
The manifest_hash allows mochi lock --check to short-circuit: if the hash matches, no resolution is needed; if it differs, re-resolve and compare the outputs byte-by-byte.
3.2 [[platform]]
An array of platform records: (os, arch, target) triples. Every package entry references platforms by index.
os is one of linux, macos, windows, freebsd. arch is one of x86_64, aarch64, wasm32. target is the transpiler target (typescript, python, jvm, c, beam, dotnet, swift, kotlin, rust).
This is the per-platform sections feature from uv's universal lockfile (May 2024). A single lockfile records the resolution for every platform the workspace targets. Per-platform package presence is recorded in [[package.platform]] index references.
3.3 [[package]]
| Field | Type | Notes |
|---|---|---|
name | string | Package name (scoped or unscoped). |
version | string | Resolved semver. |
source | string | registry:<host>, workspace, path:<path>, git:<url>@<rev>. |
blake3 | string | BLAKE3-256 hex of the source tarball (registry source only). |
sha256 | string | SHA-256 hex of the source tarball (registry source only). |
yanked | bool | True if the registry marked this version yanked at lock time. |
capabilities | string[] | The package's declared [capabilities].required set. |
path | string | Path source: relative path to the package. |
git_rev | string | Git source: revision SHA. |
Sub-table [package.dependencies] lists name → resolved version mappings for this package's deps. The version is concrete (not a range).
Sub-table array [[package.platform]] lists the indices of [[platform]] records this package is included on. A package present on all platforms records every index; a package present only on some platforms records the subset. This matches uv's marker simplification.
3.4 [capabilities_seen]
A flat map from package name to the union of capability sets the consumer audited. On mochi lock, the lockfile records each transitive dep's declared capabilities. On subsequent mochi update, if a new version of a dep would add a capability not in the seen set for that package, the user is shown a warning:
warning: @mochi/json 1.3.0 newly requires capability "net.dial"
Previously seen capabilities: ["fs.read"]
Audit and accept with: mochi lock --accept-capabilities
This is the supply-chain delta signal documented in 02-design-philosophy §6.
3.5 [provenance]
Records context useful for forensic analysis:
solver_seed: string identifying the solver invocation (timestamp; not random).registry_etag: opaque ETag of the registry index at lock time. Helps debug "I ran lock twice and got different versions" by surfacing whether the registry changed underneath.sigstore_verified_count: number of packages whose Sigstore bundle was verified at lock time.sigstore_unverified: packages whose Sigstore bundle could not be verified (warning state, not error).
4. Canonical serialisation
A bit-identical lockfile across machines requires:
- Sorted keys: every TOML table writes keys in lexicographic order. Sub-tables in arrays are written in deterministic order; the array order itself is by package name then version.
- Lowercase hex for hashes.
- Decimal integers with no leading zeros (
version = 1, notversion = 01). - No trailing whitespace on any line.
- LF line endings (no CR).
- UTF-8 NFC normalised strings.
- Specific quoting: bare keys where allowed; basic strings for values; no literal strings; no multi-line strings.
- Footer newline: file always ends with exactly one LF.
mochi lock writes a temp file, fsync, rename to the final path. Cross-platform tests verify byte-identity.
The canonical serialiser lives at pkg/pkglock/canonical.go. It is exposed via pkglock.WriteCanonical(buf, lock) and is the only path that writes lockfiles. Direct TOML encoding via the parser library is forbidden because library encoders do not guarantee canonical output.
5. Reading older lockfiles
The version = N envelope at the top dispatches a reader:
switch lock.Version {
case 1:
return readV1(buf)
default:
return errLockfileTooNew(lock.Version)
}
A v2 reader can read v1 files; a v1 reader cannot read v2 (errors clearly with a "this lock was produced by a newer Mochi; upgrade" message). The lockfile reader never silently downgrades.
6. Diff-friendliness in practice
The format prioritises diffs: each package is one block; key order is stable; hashes are on their own lines so a hash change shows as a single-line change rather than a whole-block change. Compare a small dep bump in this format:
[[package]]
name = "@mochi/strings"
-version = "0.4.6"
+version = "0.4.7"
source = "registry:index.mochi.dev"
-blake3 = "abc..."
-sha256 = "def..."
+blake3 = "fed..."
+sha256 = "cba..."
yanked = false
capabilities = []
A single dep bump is visibly four lines. A hijack attempt that changes the source URL would surface as a single-line source = change immediately under the version, which is what a human reviewer can spot.
Compare with a binary lockfile bump: one blob replaced with another. No way to tell what changed.
7. Workspace lock semantics
In a workspace, there is one mochi.lock at the workspace root. It records the resolution for all members under one [[package]] block each (with source = "workspace" and path = "packages/<name>").
When a workspace member is published, the member's mochi.toml and the member's portion of the lockfile travel together as the tarball. Consumers download the member; their solver re-resolves transitive deps because the upstream lockfile is informational from the downstream perspective.
This matches Cargo's workspace semantics: the workspace lockfile is the producer's contract; consumers do not inherit it.
8. mochi lock --check semantics
mochi lock --check is the CI gate:
- Read
mochi.tomland computemanifest_hash. - Read
mochi.lock. - If
mochi.lock.manifest_hashmatches, proceed; else fail withM057_LOCK_E001(manifest changed without lock update). - Re-resolve against the registry snapshot recorded in
[provenance].registry_etag. - Compare resolution to lockfile; any difference fails with
M057_LOCK_E002(lock out of sync).
In CI, mochi lock --check runs after git pull and before any mochi build step. A failure means "rebase and rerun mochi lock".
The registry-side change-detection is the harder case: if [provenance].registry_etag shows the index has changed since the last lock, the lock is stale and must be regenerated or the registry has rolled forward without a manifest change. This is a warning, not an error.
9. mochi.lock and merge conflicts
A merge conflict in mochi.lock is resolved with mochi lock --refresh, which:
- Reads
mochi.toml(the manifest is the source of truth). - Discards the conflicting lockfile.
- Resolves fresh.
- Writes a canonical lockfile.
No hand-merging. The format is designed so hand-merging is rarely necessary (most conflicts touch separate package blocks), but --refresh is always available as the safe path.
10. Worked example
A small workspace with two deps:
mochi.toml:
[package]
name = "@my/app"
version = "0.1.0"
license = "MIT"
edition = "2026"
mochi = ">=0.7, <1.0"
[dependencies]
"@mochi/strings" = "^0.4"
"@mochi/json" = "^1.2"
[targets]
typescript = { entrypoint = "src/main.mochi" }
python = { entrypoint = "src/main.mochi" }
After mochi lock:
# This file is generated by `mochi lock`. Do not edit by hand.
version = 1
mochi = "0.7.0"
manifest = "mochi.toml"
manifest_hash = "blake3-256:91c3..."
[[platform]]
os = "linux"
arch = "x86_64"
target = "typescript"
[[platform]]
os = "linux"
arch = "x86_64"
target = "python"
[[platform]]
os = "macos"
arch = "aarch64"
target = "typescript"
[[platform]]
os = "macos"
arch = "aarch64"
target = "python"
[[package]]
name = "@mochi/json"
version = "1.2.5"
source = "registry:index.mochi.dev"
blake3 = "e2d1..."
sha256 = "abf3..."
yanked = false
capabilities = ["fs.read"]
[package.dependencies]
"@mochi/strings" = "0.4.7"
[[package.platform]]
index = 0
[[package.platform]]
index = 1
[[package.platform]]
index = 2
[[package.platform]]
index = 3
[[package]]
name = "@mochi/strings"
version = "0.4.7"
source = "registry:index.mochi.dev"
blake3 = "7af2..."
sha256 = "9c10..."
yanked = false
capabilities = []
[package.dependencies]
[[package.platform]]
index = 0
[[package.platform]]
index = 1
[[package.platform]]
index = 2
[[package.platform]]
index = 3
[capabilities_seen]
"@mochi/json" = ["fs.read"]
"@mochi/strings" = []
[provenance]
solver_seed = "v1-2026-05-29T06:35:00Z"
registry_etag = "W/\"abc123\""
sigstore_verified_count = 2
sigstore_unverified = []
The lockfile is reproducible: re-running mochi lock against the same registry snapshot produces the same bytes.
11. Error codes
| Code | Meaning |
|---|---|
M057_LOCK_E001 | Manifest hash mismatch (lock is stale w.r.t. manifest). |
M057_LOCK_E002 | Resolution mismatch (lock disagrees with re-resolve). |
M057_LOCK_E003 | Lockfile version too new for this Mochi. |
M057_LOCK_E004 | Invalid lockfile syntax. |
M057_LOCK_E005 | Lockfile references unknown registry. |
M057_LOCK_E006 | Capability addition without --accept-capabilities. |
M057_LOCK_E007 | Hash mismatch on cached blob (BLAKE3 or SHA-256). |
12. Cross-references
- Format rationale: 02-design-philosophy §3.
- Comparable lockfiles: 03-prior-art-registries §1 (Cargo.lock), §6 (uv.lock), §5 (bun.lock reversal).
- Manifest input: 04-manifest-format.
- Solver output: 05-solver-design.
- Content hashing details: 08-content-addressed-store.
- Capability pin behaviour: 10-capability-model §6.