# Stable ids
*For campaign owners and data-layer authors who rebuild a store and need its
entry ids to stay the same.*
A database built from workflow results mints entry ids as it saves — the first
record stored becomes `--1`, the second `-2`, and so on. That
numbering follows *save order*, so rebuilding the same store from scratch, or
adding one job and rebuilding, renumbers entries and hands old ids to new
content. For a throwaway store that is fine; for one whose ids are cited, linked
to, or served, it is not.
The **id ledger** fixes this. It is an allocator that maps a stable *source key*
to a permanent id and hands the same id back for that key forever. Rebuilds stay
identical, and a change to an entry's *content* becomes a new revision under the
same id rather than a fresh entry.
## The three pieces
- **The ledger is the allocator.** It owns the per-family id bases and counters.
The store no longer mints ids for ledger-managed saves; it is handed the id the
ledger allocated. See `httk.store.IdLedger`.
- **Keys are provenance coordinates.** A key names *which produced thing* an id
belongs to, in a stable way. The httk₂-native grammar, built by
{py:func}`httk.workflow.ledger_key`, is
```text
:[:[:file:]]
```
— the producing job, optionally one of its declared output roles, optionally
one file of that output (marked by a literal `file:` segment so a role and a
file can never be confused). Keys are opaque to the allocator: embedded colons
in a role or path are harmless.
- **The segments and git are the witnesses.** The ledger file is one
`sqlite3` database. Each close that allocated something appends its new
records plus exactly **one signed segment** covering just them (the segment's
subject carries the per-family base map, the series, the ledger identity, and
the record range it signs); a close that allocated nothing leaves the file
byte-identical. Reopening verifies every segment's signature and checks that
the segments exactly partition the records. A signature is an **audit
record**, not a build gate: the reopen logs who signed each segment (the
manual-audit surface) but never demands a particular signer, unless a *pinned*
signer is required (`IdLedger.open(trusted_keys=...)`), which is optional. The
integrity self-check is always on — an edit, a middle deletion, a reordering,
or a segment/record range mismatch makes the reopen refuse. What in-file
checks cannot see is a **whole-file rollback**: dropping the newest
segment(s) together with their records leaves a self-consistent older ledger,
so that is witnessed only by **git history**. The committed file is binary;
`sqlite3 .dump` renders it for inspection. A corrupted or lost ledger
is recovered by **restoring it from git**, and the verification errors say so.
## The anchoring rule
A key must derive from a *stable* identity, never from a path that can silently
move. A live-collected job is always stable. A job harvested from a **v1 tree
with no manifest** is identified only by its absolute path, so
{py:func}`httk.workflow.ledger_key` **refuses** it
({py:class}`httk.workflow.UnstableIdentityError`, with a `force=True` escape
hatch). A path-derived key that goes stale would hand an old id to new content —
the one unforgivable ledger failure — so `collect` degrades such a job to
store-minted (unstable) ids with a loud warning rather than pinning it wrongly.
## Using it from `collect`
With `--into`, the ledger is **on by default**, kept beside the store at
`.ids.sqlite`:
| Flag | Effect |
| --- | --- |
| *(default)* | Allocate ids through `.ids.sqlite`, creating and signing it on first use (announced loudly — a keep-worthy file appears next to the store; commit it alongside the store). |
| `--id-ledger PATH` | Put the ledger somewhere else, e.g. a path committed in the database repo. |
| `--no-id-ledger` | Do not use a ledger; the store mints ids, which are **not** stable across rebuilds. Warned once. |
The ledger is signed with the workspace's own seal keys
(the workspace `seal.keys` setting, via `default_workspace_keys`).
If **no signing key** is resolvable, `collect` falls back to no ledger with a
loud warning rather than failing — a collect never dies for want of a key.
While allocating, `collect`:
- **skips** any output already carrying an assigned public id — a ledger never
overwrites one;
- **aliases** on content dedup — when the store deduplicates two jobs'
content-identical outputs onto one row, the second job's key is recorded as an
*alias* of the first's id, so both keys resolve to the one id;
- **degrades** an output it cannot hand an explicit id to (a structure view over
a record whose backing dataclass has no `id` field) to store minting; those
ids are stabilized instead where the id can be threaded through the build (the
altermagnets `build_store` path), not through `collect`.
## Bases and numbering
`collect --into` gives each family a **distinct** base `.`, so
ids are shaped `.--` — records and runs never
collide even though they share a ledger. A distinct base per family is required,
not cosmetic: the ledger enforces id uniqueness *globally* (across all
families), so a single shared base would make the first `records` id and the
first `runs` id identical and brick the next rebuild's reopen. The ledger's
counter is `max(existing number) + 1` per family — monotone and tolerant of
gaps, so an entry whose source later disappears keeps its number rather than
letting a later entry reuse it.
## Supersession and re-binding
`lookup` resolves a key to its **newest** binding. Entries are append-only and
never edited, so re-binding a key — pointing a source key at a different id when
sources are regrouped — is an explicit, recorded act, not an in-place mutation:
the old binding stays in the history and the newest one wins at lookup. `collect`
itself never re-binds; it only assigns a fresh key or aliases a new key onto an
existing id.