Domain

Problem Domain: The lock file is the serialized record of an atom's resolution: the recorded choice function over a discovery snapshot that makes executable-intent elaboration pure in (intent, fact-set) (Composition Model §5–§6). Ion (L4) writes it; Eos (L3) consumes it to fetch, verify, and form execution requests. Resolution of an atom is reconciliation over its dependencies' locked worlds, plus the atom's own constraints, plus its declared overrides — the lock records the ground result and nothing else.

The lock is deliberately small. Five concepts: set anchors with discovery snapshots, ground dependency pins with a requires graph, promoted fetch pins, and a schema version. Everything else that once seemed lock-shaped lives elsewhere by decision: constraints, params, overrides, and toolchain-role defaults in the manifest (which ships inside the atom snapshot); interface manifests and build records on the atom metadata chain; adopted ecosystem lockfiles inside the atom's sources.

Model Reference:

  • Composition Model — §4 (reconciliation; single choice per name per scope), §5 (two strata of intent; the lock/certificate symmetry), §6 (fact-set; snapshot pinning; recorded choice)
  • Execution Model — §2.4 (action_id; identity discipline), §3.3 (promotion; adopted lockfiles), §8 (P5–P7)
  • ADR-0006 — §3 (no evaluator: no composer, ever)
  • ion-eos-contract.md — handoff boundary (predates this spec; see Supersessions)

Criticality Tier: High — the lock is a trust boundary. Every value plays exactly one of three roles: a cryptographic commitment (anchor, charter_head, snapshot, publish, digest), a non-authoritative transport hint (mirrors, url), or a structural annotation (schema, version, requires). Annotations are never the basis of a fetch or trust decision and MUST be consistent with the commitments ([lock-annotation-consistency]).

Formal requirements

These bind the schema as a whole; every section constraint below serves one of them.

[lock-sufficiency]: The lock MUST pin every worldly discovery needed to form execution requests for the atom: the transitive dependency closure by content identity, and every fetch payload not adopted from an ecosystem lockfile in the atom's sources. Build-time resolution or discovery of any kind MUST NOT be required by a consumer holding the atom snapshot and its lock. RESIDUE: Phase 2 -- whole-closure sufficiency is a resolution-time property; no resolver exists yet (ion-resolve/src/lib.rs: "A full SAT-based resolver and lock file generator are planned but not yet implemented")

[lock-recomputability]: The lock MUST be the output of deterministic resolution: the same manifest and the same discovery snapshot (as pinned by [lock-set-snapshot]) MUST re-derive a byte-identical lock. Serialization MUST be canonical (see [lock-canonical-form]). RESIDUE: Phase 2 -- depends on both the canonical encoder and resolution determinism, neither implemented (LockFileV2::to_canonical is an explicit unimplemented!() stub; no resolver exists)

[lock-choice-policy]: The choice function is fixed and canonical: among candidates satisfying the manifest's constraints, resolution MUST select the highest version under the atom's declared scheme order, deviating only through declared, manifest-side overrides. Determinism in [lock-recomputability] is therefore two-place — (manifest, snapshot) — with the choice policy a constant of this specification (Composition Model §6: the recorded choice). RESIDUE: Phase 2 -- the choice function is part of the not-yet-implemented SAT-based resolver (ion-resolve/src/lib.rs)

[lock-groundness]: Every lock value MUST be ground: names bound to content identities and exact version strings. Version constraints, ranges, override declarations, and any other unresolved intent MUST NOT appear in the lock; they are manifest-side. The only equality relation a lock consumer needs is syntactic. RESIDUE: Phase 2 -- a whole-schema meta-invariant; no check beyond the type shape exists, and the type shape alone does not prevent e.g. a version range being stuffed into the version String field

[lock-closure-completeness]: The [deps] section MUST contain the full transitive closure of the atom's dependencies as resolved. Which entries are direct is the manifest's knowledge and MUST NOT be duplicated in the lock (no root marker, no [self]). RESIDUE: Phase 2 -- requires a manifest-driven resolution pipeline to compute the closure and know which entries are direct; no such pipeline exists

[lock-action-totality]: The lock, together with the manifest in the same atom snapshot, MUST determine — by pure elaboration, with no discovery — all three inputs of the atom's action_id (Execution Model §2.4): atom_czd_closure_root from the dep pins; params from the manifest's declarations; and the effective toolchain_composition_root from the manifest's toolchain-role declarations as resolved in the lock, after role-keyed override propagation. For closure members, the lock MUST let a consumer identify and fetch each member's snapshot, from which that member's action_id is computed the same way. This discharges P7's totality gate (Execution Model §8): the toolchain pin is an ordinary dep entry; no dedicated entry type exists or is needed. RESIDUE: Phase 2 -- action_id computation combining lock + manifest + toolchain-override propagation is not implemented anywhere in atom-core or eos

[lock-in-snapshot-locality]: The lock is carried inside the atom snapshot it serves — it is reviewed, signed intent. An edit to an atom's lock therefore legitimately changes that atom's own action identity (its pinned inputs changed). What MUST NOT happen is cross-action leakage: the identity of any other action — a dependency's build, another atom's job — MUST NOT be derived from this atom's lock; each action's identity derives only from its own snapshot's slice (see [lock-no-plan-digest]). Canonical form exists partly for this reason: with exactly one legal serialization and no comments, no semantically-inert lock edit exists to spuriously shift identity. RESIDUE: Phase 2 -- cross-action leakage prevention depends on the same unimplemented action_id computation as [lock-action-totality]

Top level

[lock-schema-version]: The lock MUST contain a top-level integer field schema, in minimal decimal form. This document specifies schema = 2. Consumers MUST refuse locks whose schema version they do not implement. RESIDUE: Phase 1 -- LockFileV2.schema is a typed u64 field, but no code path rejects an unrecognized schema version; no such check or test exists (ion/ion-lock/src/v2.rs)

[lock-tool-owned]: The lock is tool-authored; humans review lock diffs and never hand-edit them in supported workflows. The canonical form contains no comments: generator provenance belongs in VCS metadata and tool output, never in lock bytes. RESIDUE: Phase 2 -- "no comments in canonical form" is tied to the unimplemented to_canonical encoder; "humans never hand-edit" is a workflow discipline, not machine-checkable by this repo's tooling

<code>[sets]</code> — set anchors and discovery snapshots

[lock-set-anchor]: Each key under [sets] is a local set alias; its anchor field MUST be the algorithm-prefixed coz digest of the atom-set's founding charter transaction: anchor = czd(charter₀) (atom-transactions.md's [charter-anchor]). The anchor IS the set's identity, and this derivation is backend-agnostic: the value is a coz digest regardless of which VCS backend hosts the set's source. This supersedes the prior genesis-commit derivation. The founding commit is not lost — charter.src is the chartering-point revision, and because a revision hash commits to its entire ancestry, charter.src transitively pins the genesis commit (atom-transactions.md's [charter-ancestry]) without itself being the genesis commit or serving as the set's identity; that role now belongs one layer up, to anchor. A weak source-repository hash — SHA-1 is not collision-resistant (Storage Model §5) — therefore no longer bears on the lock's own identity value directly: it bears only on the ancestry charter.src transitively pins, not on anchor itself. Continuity across a hash-migrating history rewrite is a successor charter's explicit concern (atom-transactions.md's [charter-succession], [anchor-hash-agile]), never this field's: anchor is immutable for the set's lifetime regardless of what happens to the source repository underneath it. RESIDUE: Phase 2 -- golden_digest_fields_are_byte_exact_canonical_form (ion/ion-lock/tests/v2_corpus.rs) confirms the field decodes/encodes as a well-formed AtomDigest, but not that the value genuinely equals czd(charter₀); no code computes a lock's anchor from charter machinery yet

[lock-set-charter-head]: Each [sets] entry MUST record a charter_head field: the algorithm-prefixed coz digest of the charter transaction — the founding charter, or a later successor — that this lock's resolution observed as the effective charter (atom-transactions.md's [charter-succession-linear]: the head of the unique valid succession chain, ordered by chain position, never by the untrusted now field). This is the lock's contribution to atom-transactions.md's [chain-monotonicity], which explicitly requires it: a consumer holding this lock MUST refuse to accept a served succession chain that regresses below the recorded charter_head — reproducing a prefix of previously observed chain state is a detected rollback, never an alternative history. charter_head MUST equal anchor exactly while the set has undergone no succession (the founding charter is still effective); it diverges from anchor — both remaining valid digests of real charter transactions — precisely when a successor charter (key rotation or ownership transfer, atom-transactions.md's [charter-succession]) is the effective charter at resolution time. Unlike anchor, which is immutable for the set's lifetime, charter_head MAY advance on re-resolution as succession proceeds; it MUST NOT regress. RESIDUE: Phase 2 -- charter_head_is_distinct_from_anchor (ion/ion-lock/src/v2.rs) only shows the two fields CAN hold different values; the substantive non-regression/succession-derivation invariant depends on the chain-monotonicity machinery, which is Phase 1 unimplemented (atom/atom-id/tests/charter/chain_monotonicity.rs)

[lock-set-snapshot]: Each [sets] entry MUST record a snapshot field: the algorithm-prefixed object id of the set repository's tip commit at discovery time. This pins the discovery snapshot (Composition Model §6): re-resolving the same manifest against the same set snapshots MUST re-derive the byte-identical lock, making [lock-recomputability] auditable rather than aspirational. RESIDUE: Phase 2 -- same digest-format-only reasoning as [lock-set-anchor]; re-derivation determinism depends on the not-yet-implemented resolver

[lock-set-mirrors]: The mirrors field MUST be an array of transport hints: URLs, or the "::" sentinel denoting the local workspace source (no remote). Mirrors are NEVER identity and NEVER trusted: content fetched from any mirror MUST verify against the content identities in this lock. Consumers MAY consult mirrors from any other source. RESIDUE: Phase 1 -- SetEntry.mirrors is a raw Vec<String>; no validation of URL shape or the "::" sentinel exists

[lock-set-referenced]: Every set alias appearing in a [deps] key path MUST have an entry under [sets], and every [sets] entry MUST be referenced by at least one dep entry. VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::unreferenced_set_is_rejected, undeclared_set_is_rejected -- tests/support/mod.rs::check_closure checks both directions)

<code>[deps.&lt;set&gt;.&lt;label&gt;]</code> — the ground pins

Dependency entries are nested tables keyed by set alias, then atom label. This two-level keying is the (set, label) name anchor; there is no per-entry type dispatch and no set field.

[lock-single-version]: A lock MUST bind at most one version per (set, label) — the nested keying makes this structural. Within one atom's closure, resolution reconciles to a single shared choice per name (Composition Model §4); diamond requirements that cannot reconcile are a resolution failure at this layer. Divergent-version coexistence is scope territory — environment certificates and co-installation — never package-lock state. RESIDUE: Phase 2 -- the nested (set, label) keying structurally prevents two versions under one key, but the substantive claim (diamond requirements reconcile to a single shared choice) is SAT-resolution logic, not implemented (ion-resolve/src/lib.rs)

[lock-dep-version]: The version field MUST be the exact, non-empty version string of the resolved publish, recorded byte-verbatim as published (raw scheme — no normalization of any kind; scheme interpretation is a manifest/resolution concern, never a lock concern). RESIDUE: Phase 1 -- DepEntry.version is a plain String; non-emptiness and byte-verbatim-as-published are untested and unenforced (ion/ion-lock/src/v2.rs)

[lock-dep-publish]: The publish field MUST be the content digest of the resolved publish transaction (the bare publish czd), prefixed with its hash algorithm. This is the entry's identity. RESIDUE: Phase 2 -- golden_digest_fields_are_byte_exact_canonical_form (ion/ion-lock/tests/v2_corpus.rs) verifies AtomDigest format round-trip only, not that the value is genuinely the resolved publish transaction's digest

[lock-annotation-consistency]: version and the (set, label) key path MUST equal the values derivable from the entry's publish transaction. A mismatch is a hard validation error: annotations exist for humans and indexing, never as independent authority. RESIDUE: Phase 2 -- no code cross-references version/(set, label) against the publish transaction's own payload; would require decoding the signed publish CozMessage during lock validation, not implemented

[lock-dep-requires]: The requires field MUST be an array listing the entry's direct dependencies as dotted key paths ("<set>.<label>" for dep entries, "fetch.<name>" for fetch entries), sorted bytewise. Requires edges are the closure's graph structure. Provider-side owner back-pointers MUST NOT exist. RESIDUE: Phase 2 -- resolvability/acyclicity of the requires graph are separately verified ([lock-requires-resolvable], [lock-requires-acyclic]), but bytewise-sortedness of the array is a canonical-serialization concern tied to the unimplemented to_canonical encoder

[lock-requires-resolvable]: Every requires edge MUST name an entry that exists in this lock. Dangling edges are a hard validation error. VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::dangling_requires_edge_is_rejected -- tests/support/mod.rs::edge_resolves)

[lock-requires-acyclic]: The requires graph MUST be acyclic — it is the skeleton of the action DAG, and a cyclic build dependency is unrealizable. VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::cyclic_requires_graph_is_rejected -- tests/support/mod.rs::detect_cycle)

[lock-dep-liveness]: Dep-entry liveness is reachability from the manifest's direct dependencies — the graph's roots, which live manifest-side per [lock-closure-completeness]. The lock alone is deliberately not a self-contained GC domain: sanitization MUST take its roots from the manifest. RESIDUE: Phase 2 -- no manifest-linkage or sanitization/GC pass exists yet; depends on the same unimplemented resolution pipeline as [lock-closure-completeness]

[lock-dep-ordering]: Dep entries MUST be serialized in bytewise lexicographic order of set alias, then label. (Fetch entries sort independently within [fetch]; no cross-section ordering relation exists or is needed.) RESIDUE: Phase 2 -- serialization ordering is part of the canonical encoder, an explicit unimplemented!() stub (LockFileV2::to_canonical, ion/ion-lock/src/v2.rs)

<code>[fetch.&lt;name&gt;]</code> — promoted fetch pins

Fetch entries record promoted discoveries (record-mode trial → reviewed, signed intent; Execution Model §3.3). Origin coincides with section: everything under [fetch] is promotion-authored and NOT regenerable by resolution.

[lock-fetch-digest]: Each fetch entry MUST contain a digest field: the algorithm-prefixed content digest of the fetched payload. The digest is the identity; the url field is a transport hint and MUST NOT be treated as authoritative. RESIDUE: Phase 2 -- golden_digest_fields_are_byte_exact_canonical_form (ion/ion-lock/tests/v2_corpus.rs) verifies AtomDigest format round-trip only, not that url is never treated as authoritative by any consumer (no fetch/verify path exists yet)

[lock-fetch-naming]: Fetch names are lock-local labels with no cross-lock meaning. The promoting tool MUST derive names deterministically from the discovery context, and a promotion whose name collides with an existing entry carrying a different digest MUST fail loudly for user resolution. (TOML itself rejects duplicate keys; this constraint binds the promoting tool, not the parser.) RESIDUE: Phase 2 -- no promotion/promoting-tool implementation exists yet (record-mode fetch proxy is unimplemented; see ion-manifest.md's P4 flag)

[lock-fetch-liveness]: A fetch entry is live while at least one requires edge references it. Automated sanitization MUST NOT remove a live fetch entry, and MUST NOT remove a dead one except under an explicit user-invoked purge — promoted knowledge is not regenerable and its removal is a user decision. RESIDUE: Phase 2 -- no sanitization/purge implementation exists; ion-lock is currently parse/validate-only

[lock-fetch-adopted-absent]: Dependencies pinned by an adopted ecosystem lockfile (e.g. Cargo.lock shipped inside the atom's sources) MUST NOT be re-declared as fetch entries. The adopted lockfile is the pin payload, already inside the atom snapshot. RESIDUE: Phase 2 -- cross-referencing adopted ecosystem lockfiles against fetch entries requires atom-sourcing pipeline integration, not implemented anywhere

Canonical form and write discipline

[lock-canonical-form]: Serialization MUST be canonical, defined concretely as: UTF-8, LF newlines, exactly one terminating newline; no comments; fixed section order (schema, [sets], [deps], [fetch]); keys sorted bytewise at every nesting level; exactly one blank line between tables and none elsewhere; bare keys wherever TOML permits, otherwise basic (double-quoted) keys; all string values as basic strings with TOML's minimal escaping; exactly one space on each side of =, no alignment padding; arrays inline with elements separated by ", "; integers in minimal decimal form; version strings byte-verbatim as published (no Unicode normalization). Two locks with equal content MUST be byte-identical. RESIDUE: Phase 2 -- LockFileV2::to_canonical is an explicit unimplemented!() stub by design (ion/ion-lock/src/v2.rs); canonical_form_is_byte_exact is a red test (#[ignore], "canonical encoder is unimplemented by design")

[lock-atomic-write]: Every lock mutation MUST be a whole-file atomic write. Write phases (resolution; later promotions) land in their own sections; independent promotions MUST be reorder-invariant — running two independent promotions in either order MUST yield the same bytes (Execution Model §8, P6). RESIDUE: Phase 2 -- no lock-writing code exists at all; ion-lock is currently a parse/validate-only crate (no LockFileV2 writer, temp-then-rename, or promotion-merge path)

Deliberate absences

Each absence is a decision with a source; adding any of these is a regression, not an extension.

[lock-no-compose]: The lock MUST NOT contain a [compose] section, composer selection, or evaluator arguments of any kind (ADR-0006 §3). VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors, typed_parse_alone_silently_accepts_a_lock_no_star_violation -- corpus/v2/violations/lock-no-compose.toml)

[lock-no-params]: Action parameters MUST NOT appear in the lock. They are declared in the manifest, which ships inside the atom snapshot (they are declared, not resolved). VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors -- corpus/v2/violations/lock-no-params.toml)

[lock-no-toolchain-section]: The lock MUST NOT contain a toolchain section or toolchain entry type. Toolchain-role defaults are ordinary dependencies declared in the manifest and pinned as ordinary dep entries; the effective toolchain after role-keyed override propagation is computed per [lock-action-totality], never stored. VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors -- corpus/v2/violations/lock-no-toolchain-section.toml)

[lock-no-interfaces]: Interface manifests MUST NOT appear in the lock. They are facts, not choices; they live on the atom metadata chain beside build records. VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors -- corpus/v2/violations/lock-no-interfaces.toml)

[lock-no-override-state]: Override declarations (target-keyed or role-keyed, forced or bounded) MUST NOT appear in the lock. The lock records their ground results; the pin-diff is the audit trail. VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors -- corpus/v2/violations/lock-no-override-state.toml)

[lock-no-foreign-metadata]: The lock MUST NOT contain registry metadata, timestamps, resolution history, environment markers, or conditional entries of any kind. (The snapshot field is a content identity, not a timestamp.) VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors -- corpus/v2/violations/lock-no-foreign-metadata.toml)

Whole-lock hashing is not an identity

[lock-no-plan-digest]: Consumers MUST NOT use a digest of the whole lock file as a cache key, plan identity, job identity, or build identity for any action other than the owning atom's own (whose snapshot legitimately contains the lock, per [lock-in-snapshot-locality]). Identity is per-action (action_id, Execution Model §2.4): an edit in one atom's lock MUST NOT shift the identity of dependency actions or other atoms' actions. (This retires the v1-era plan_digest and the job-identity scheme built on it.) VERIFIED: test (ion/ion-lock/tests/v2_corpus.rs::lock_no_star_violations_are_forbidden_key_errors -- corpus/v2/violations/lock-no-plan-digest.toml)

Example

schema = 2

[sets.core]
anchor = "sha256:3fa1c9e0…"
charter_head = "sha256:3fa1c9e0…"
mirrors = ["::", "https://mirror.example.org/core"]
snapshot = "sha1:b03d55e1…"

[deps.core.gcc]
publish = "sha256:57de9a02…"
requires = []
version = "13.3.0"

[deps.core.libfoo]
publish = "sha256:7be13c55…"
requires = ["core.openssl", "core.zlib-ng", "fetch.libfoo-vendor-models"]
version = "2.1.4"

[deps.core.openssl]
publish = "sha256:c2104e88…"
requires = []
version = "3.0.16"

[deps.core.zlib-ng]
publish = "sha256:e9973b19…"
requires = []
version = "2.2.1"

[fetch.libfoo-vendor-models]
digest = "blake3:aa31f6c0…"
url = "https://files.example.com/models-4.2.tar.zst"

Supersessions and open items

  • This spec supersedes the v1 lock schema wholesale. The older boundary specs (ion-eos-contract.md, ion-resolution.md, ion-manifest.md) carry v1-era constraints — plan_digest-keyed job identity, plugin type dispatch, owner tracking, [[deps]] arrays, semver mandates — that this spec invalidates; their reconciliation is tracked follow-up work, with the job-identity re-key (plan_digestaction_id) sequenced first as a live contract boundary.
  • The publish czd's digest algorithm is whatever coz produces — the concrete prefix string MUST be pinned from atom-core before implementation (placeholder above: sha256). anchor and charter_head share this open item rather than a separate one: both are now coz czds ([lock-set-anchor], [lock-set-charter-head]), not git object ids, so both carry the same pending prefix pin.
  • The manifest schema (constraints, overrides, toolchain roles, ecosystem declaration, params) is a separate specification; this spec constrains only what crosses into the lock.