# Lockfile

> esker.lock — format, lifecycle, hand-emitted TOML.

`esker.lock` is the project-scoped pin file, sibling to `pyproject.toml`. It records the exact version, content hash, and lineage hash of each bound dataset, so a clean clone + `esker sync` reproduces the cache byte-for-byte.

Created and maintained by `esker add`, `esker upgrade`, `esker remove`. Read by `esker sync`. Hand-edits are lossy on the next mutation.

## Format

```toml
# generated; do not edit

[[dataset]]
name           = "us.treasury.yields"
owner          = "archie"
version        = "1.0.0"
schema_version = "1.0.0"
content_hash   = "sha256:79eeb5cb20248d4efa4fc516cb517f0afcd2e2d83bcdef63d9860de7ee3b613e"
lineage_hash   = "sha256:5ef35cc43463ca5624f299a4bfd2a23a67327639072eb4af2008912667e523da"
resolved_at    = "2026-04-27T15:20:08.878000+00:00"
```

Conventions:

- Comment header is literal `# generated; do not edit`. Always rewritten.
- One `[[dataset]]` block per binding. Sorted alphabetically by `name`.
- Padding aligns the `=` (looks like `name           =` with the value flush).
- `lineage_hash` line omitted if `None`.
- Trailing blank line, then a final newline.

Reading uses `tomllib` (stdlib, Python 3.11+). Writing is hand-emitted — no TOML write dependency in scope.

## Per-entry shape

| field            | source                      | what                          |
| ---------------- | --------------------------- | ----------------------------- |
| `name`           | binding key                 | dataset domain                |
| `owner`          | binding value's owner       | publisher handle              |
| `version`        | manifest's `schema_version` | resolved version              |
| `schema_version` | manifest's `schema_version` | record-shape contract         |
| `content_hash`   | manifest's `content_hash`   | sha256 of parquet bytes       |
| `lineage_hash`   | manifest's `lineage_hash`   | sha256 of `lineage.json`      |
| `resolved_at`    | manifest's `published_at`   | when the version was resolved |

`version` and `schema_version` are currently always equal — the dataset's evolution sequence and its record-shape contract use the same number. They're stored as separate fields for forward-compat; if they diverge in a future release, both will be tracked.

## Lifecycle

| event                      | mutation                                             |
| -------------------------- | ---------------------------------------------------- |
| `esker add <owner>/<name>` | inserts/replaces `[[dataset]]` row from the manifest |
| `esker remove <name>`      | drops the row                                        |
| `esker upgrade <name>`     | replaces the row with the latest manifest's data     |
| `esker sync`               | reads only — never mutates                           |

## Project root

The lockfile lives next to the nearest `pyproject.toml` in the cwd's parent chain. So `cd`-ing into a subdirectory and running `esker add` writes to the project root, not the subdir.

## Hand-edits are lossy

Manual edits to `esker.lock` (extra fields, comments other than the generated header, custom keys) are dropped on the next `add`/`upgrade`/`remove` rewrite. The header `# generated; do not edit` is the warning.

If you want to change a pin, edit the binding in `pyproject.toml` instead — the lockfile follows.

## Sync semantics

`esker sync` reads the lockfile and reconciles the local cache. Per entry:

1. Fetch the manifest from the hub.
2. If the hub's `content_hash` differs from the lockfile entry → report drift, exit non-zero. **Does not auto-upgrade.**
3. Otherwise, ensure the parquet is in `~/.esker/cache/<owner>/<name>/<version>/data.parquet` and content-hash verifies.

Hash drift is treated as a real error, not a silent recover. The fix is `esker upgrade <name>` — explicit, per-name. That's intentional: drift is a security signal, not a routine event.

## Pinned bindings vs lockfile

A versioned binding (`<owner>/<name>@<version>` in `pyproject.toml`) short-circuits the lockfile entirely for that dataset — `pyproject.toml` is the durable intent.

An unversioned binding consults the lock for the pin. The lock is the auto-tracked latest, written by `add` and `upgrade`.

Global bindings have no lockfile. They always resolve to "latest" at fetch time.

## See also

- [Bindings](https://esker.so/docs/sdk/bindings.md) — the project / global / lockfile resolution order
- [CLI bindings](https://esker.so/docs/cli/bindings.md) — `add`, `remove`, `sync`, `upgrade`
- [Manifests](https://esker.so/docs/protocol/manifests.md) — source of truth for the lock entry's hashes
