ship.lock

How a repository pins a block, what the lock records, and what check and run read (#81, decided by ADR 0011).

The rule underneath everything: a reference is a request; the lock is the answer, and the answer is written once. ship.ks says what a repository wants, ship.lock says what it got, and nothing re-derives the second from the first — which is what makes "this repo runs the same bytes as last month" a claim you can check.

Writing a reference

A reference is an owner/name and a pin, acme/web-service@v3. Three spellings, three meanings:

SpellingMeansMoves when
@v3 (or @3)the highest published release of major 3you run keepshipping blocks update
@3.1.2exactly 3.1.2never — it is already the answer
@sha256:<hex>exactly those bytes; the version is a labelnever

The file

Written by keepshipping blocks update, committed, reviewed like any lock file. A strict subset of TOML — lock, then one [[block]] per reference, one field per line:

# ship.lock: written by `keepshipping blocks update`. Commit it; do not edit by hand.
lock = 1

[[block]]
name    = "acme/web-service"
request = "v3"
version = "3.4.1"
digest  = "sha256:9f2c1b…"

Entries are keyed by name and request together and sorted by them, so two majors of one block are two entries and the file's order never depends on the order things were resolved in. One reference is one entry: listing acme/web-service@v3 twice in ship.ks writes one entry, and two different resolutions of one reference are an error rather than two entries.

What the parser refuses

A lock file is read by people, so every refusal carries a line number:

ship.lock:8: unknown field `repository`: a block is name, request, version, digest and optionally source

Refused: an unknown lock version, an unknown table, an unknown or repeated field, a missing field, a duplicate (name, request), a malformed version or digest, an unquoted or escaped value (this format has no escapes — no field can hold one), a value holding a ", a \ or a control character, and an entry that contradicts its own request: a request = "3.1.2" whose version is 3.1.3, a digest-pinning request whose digest differs, or a request = "v3" whose version is not a release of major 3. A v3 request does not name a version, but it does constrain one — version = "not-a-version" answers nothing. That last one matters most — check reads this file, and an entry that disagrees with itself is a lock that will not mean what it says.

Comments are whole lines (a leading #), never trailing; a canonical file parses and re-renders byte-identically.

Stability

lock = 1 is a promise, not a counter. Within a version: fields keep their meaning, a new field may only be optional and ignorable, the canonical layout does not change, and entries stay sorted. What bumps it: a field becoming required or changing meaning, a field being removed or renamed, or the layout changing. A build meeting an unknown version refuses the file rather than guessing — a misinterpreted lock is worse than a rejected one.

What an upgrade looks like

 # ship.lock: written by `keepshipping blocks update`. Commit it; do not edit by hand.
 lock = 1

 [[block]]
 name    = "acme/web-service"
 request = "v3"
-version = "3.4.1"
-digest  = "sha256:9f2c1b…"
+version = "3.5.0"
+digest  = "sha256:4d81af…"

Two lines — the goal of the layout. Nothing re-ordered, nothing reformatted, and a lock with twenty entries does not reprint its other nineteen.

Two repositories, two majors

Repositories move on their own schedules, so one block can be pinned differently by two repos at once — repo A on @v3 (3.4.1), repo B on @v4 (4.0.0). Both work, and neither can be served the other's bytes: B's 4.0.0 fails against A's digest. One repository can hold both entries (a monorepo, or one mid-migration), and each step resolves through its own.

How check and run use it

Both read the digest and neither ever resolves a version. That is what keeps check inside its cold-1s/warm-100ms budget and the offline CI job passing: no round trip, no store read, no answer that can change between two checks of an unchanged file.

The cache is content-addressed by digest, so a poisoned entry cannot be substituted for another block, and a cached entry is verified by hashing on the way in.

What is not built yet

A lock file documenting a command that does not exist is worse than no documentation, so:

Reference

ThingWhere
format, syntax, selectcrates/core/src/lock.rs (ks_core::lock)
resolution and verificationcrates/engine/src/blocks.rs (ks_engine::blocks)
the BlockSource portcrates/core/src/ports/block_source.rs
the decisionADR 0011