doubleentry 0.5.0

An immutable, tamper-evident double-entry bookkeeping engine. Balanced-by-construction entries, exact integer money, deterministic identity, and an append-only Merkle log with inclusion and consistency proofs. Zero I/O. No async. No chart of accounts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# Changelog

Notable changes to `doubleentry`, in the format of
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioned per
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

**Nothing has been published to crates.io yet.** `v0.1.0` through `v0.4.0` are
development tags; `0.5.0` is the first release intended to reach the registry.
Until 1.0 every release may break, and this one does — the reasoning is in each
entry, because a breaking change without a reason is just churn.

## [0.5.0] — 2026-08-17

### ⚠️ The account-binding commitment changed

`account_binding_leaf` now covers the handle and the path and nothing else. The
classification, open window and balance limit are **out** of it. That moves two
vectors:

| Vector | before | after |
|---|---|---|
| Account binding commitment | `65ca7c50…` | `17d6ae22…` |
| Reference seal hash | `7f6f0218…` | `5dbfe84f…` |

The entry hash, the trial-balance leaf and every Merkle constant are
**unchanged**, as are all proof-path vectors. No schema change. Nothing is
published to crates.io, so there is no migration.

### Fixed

- **A sealed balance became unnameable the moment anything about the accounts
  changed.** `Seal::accounts` exists so a trial-balance handle can be resolved to
  an account, and `BalanceProof::verify_naming` documents that as the complete
  claim an auditor wants — but the only way to build the second half was
  `AccountRegistry::prove_binding`, which proves against the registry *as it
  stands now*. Register one more account and every already-sealed balance stopped
  verifying. Silently: `verify_naming` returned `false`, which reads as "the
  books are wrong" rather than "you built the proof against the wrong registry".

  The deeper cause was that the binding leaf hashed the whole `Account`,
  including three fields the registry's own mutators exist to change. So
  `close()` and `set_limit()` — routine master data — also retroactively
  invalidated every binding proof against every seal ever issued. That made
  truncating the record list to `seal.accounts.size` an unsound workaround: it
  recovers the *set* of accounts at that size but not their master data as of
  then.

  Two changes. The leaf now covers only the handle and the path — the account's
  identity, and precisely the fields that never change, which is the line
  `AccountRegistry::restore` already drew ("the path is immutable and everything
  else is master data"). And `AccountRegistry::prove_binding_at(id, size)` proves
  against the commitment the registry had at a size, mirroring
  `MerkleLog::inclusion_proof_at`. Pass `seal.accounts.size` and the proof
  verifies under `seal.accounts`, whatever has happened since.

  `AccountBindingProof` now carries `id` and `path` instead of an
  `AccountRecord`, because a proof should carry exactly what it establishes —
  reading `closed_on` off a "verified" proof that never covered it is the
  opposite mistake. `account_binding_leaf` takes `(AccountId, &AccountPath)`.

- **Proving a sealed balance through a `LedgerStore` was impossible.** A seal
  commits to the closing balance folded by *booking date*; the trait only exposed
  `trial_balance(size)`, which folds by *log prefix*. Sealing March in April is
  the normal case, so at seal time the log already holds April entries and the
  two answers differ — meaning no caller could reconstruct the commitment a seal
  recorded, and the natural attempt produced one that silently did not match.

  `LedgerStore::trial_balance_through_date` is now part of the trait (it existed
  as a private method on both SQL backends), and `LedgerStore::prove_sealed_balance`
  is a provided method that does the whole recipe — find the seal, rebuild the
  closing balance the way the seal built it, **check the rebuild against the
  seal**, prove the row, prove the binding at `seal.accounts.size` — returning a
  `SealedBalance`. The middle step is the one that matters and the one nothing
  previously forced: skip it and you hold a proof against a commitment you
  computed yourself, which is internally consistent and evidence of nothing.
  A mismatch is now `SealedBalanceError::Restated` and no proof is returned.

- **A sealed closing balance could be restated afterwards by an ordinary
  booking.** This was a soundness defect, not a hardening opportunity: a seal
  claims its closing balances are exact — every entry booked on or before the
  period's last day and nothing else — and two entirely legal writes could
  falsify that claim while the seal, its balance proofs, its binding proofs and
  the whole chain went on verifying byte for byte.

  Both routes came from the same missing rule. Sealing March while February was
  still `Open` left February accepting postings that fold into March's
  cumulative closing balance; and a date that no period covered reported `Open`
  forever, so a booking into an undefined February did the same thing even when
  the calendar had never mentioned it.

  `PeriodCalendar` now carries a **sealed watermark** — the greatest end date
  among its sealed periods, maintained as they seal and never moving backwards.
  `state_on` consults it first, so every date at or before it reports `Sealed`
  whether or not a period covers it: a gap below a seal is not an opening to
  book through, it is a range already committed to. `PeriodCalendar::sealed_through`
  exposes it.

  `PeriodCalendar::check_sealable` is the new single home for the sealing
  preconditions — defined, `Closing`, every earlier defined period already
  sealed, and ending after the watermark. `Journal::seal_period` and both SQL
  backends call it instead of each re-implementing the first two checks and
  neither implementing the last two. The conformance suite fails a backend that
  seals out of order, so this is part of what a `LedgerStore` *is*.

- **A pruned log built proofs for the wrong entries.** The cold tier's protocol
  ends "only then may the operational store drop the rows", and the `LedgerStore`
  contract said in the same breath that entries "are never modified or removed".
  Both could not be true, and the consequence of resolving it in favour of the
  cold tier was undocumented and bad.

  Proofs are built from the leaves a store holds, so removing an archived prefix
  renumbers every leaf after it. The tree head does not notice — it is read from
  the last row's stored root — so head and proofs disagreed silently. Measured on
  a ten-entry log with the first five pruned: `prove_inclusion(7)` reported
  `IndexOutOfRange { index: 7, size: 5 }` for an index that is genuinely in
  range, and `prove_inclusion(3)` returned a proof for **log entry 8**, caught
  only if the caller verified before handing it to an auditor.

  Both SQL backends now check that the log they read back is dense from zero and
  return `LogNotDense` naming the hole — the same "checked, not trusted" the
  accumulator's subtree cover already got, applied to the leaf set it was
  missing from. The contract and the cold-tier protocol now agree with each
  other and say what pruning costs.

- **`open_items` was unbounded.** `page` and `statement` were both paged — "an
  account statement over ten years is not a response body" — while open items on
  the same account came back as one `Vec` of whatever size. That is the same
  hazard the crate names elsewhere as "the difference between a report and an
  outage", and a receivables control account is exactly where it bites.

  `LedgerStore::open_items` now takes a `PostingCursor` and returns an
  `OpenItemPage`, in the same log order and behind the same cursor as a
  statement — they are the filtered and unfiltered views of the same postings, so
  they now read alike. `Journal::open_items` stays unpaged, as
  `Journal::statement` does: an in-memory journal already holds everything.

  `OpenItem` gained `position: PostingPosition`, which is what the list is
  ordered by and what a page resumes after. `PostingPosition` moved from
  `storage` to `clearing`, beside `PostingRef` — the two are the ways of
  addressing a posting, and the distinction is load-bearing: a reference *names*
  one by entry identifier, a position *locates* it in the log.
  `StatementCursor` is renamed `PostingCursor`, since it now pages both.

- **Open items came back in entry-identifier order, not oldest first.**
  `ClearingRegister::open_items` sorted by `PostingRef`, which orders by entry
  **identifier**. Identity is caller-supplied — the engine never generates one on
  the deterministic path — so that ordering is chronological only when a caller
  happens to use `EntryId::generate()`, whose UUIDv7 values are time-ordered.
  Bring your own identifiers and the list silently reverses. FIFO clearing is
  what open items are *for*, so this was the wrong order, arrived at by an
  ordering the crate rejects everywhere else (`LogIndex`: "a wall clock is
  neither monotonic nor agreed between writers, and the index is both").

  The register now imposes no order — it returns candidates as supplied, because
  it knows nothing about the log and cannot honestly claim age — and the journal
  and both SQL backends supply them in log order. The conformance suite checks
  it, with a fixture carrying **descending** identifiers so the two orders are
  actually distinguishable; with `EntryId::generate()` throughout they coincide
  and a wrong implementation passes by luck.

- **Paging a statement silently dropped postings.** `StatementPage::next`
  handed back a `Cursor`, which addresses an *entry* — but a statement is a list
  of **postings**, and one entry may put several on the same account. A split
  receipt booked as three lines against one credit is an ordinary entry, so a
  page boundary can fall inside one. Resuming then asked for
  `log_index > after`, skipping every remaining posting of that entry:
  permanently, since the cursor had already moved past it, and invisibly, since
  the running balance stayed internally consistent across the gap.

  All three backends had it, and the "statement pagination is exact" conformance
  check missed it because its fixture never put two postings on one account —
  every boundary fell on an entry edge, so an entry-addressed cursor passed by
  luck. The check now seeds a three-posting entry *and* asserts that some entry
  really did contribute two adjacent lines, so it cannot quietly stop exercising
  the boundary.

  New `PostingPosition` (log index + posting index, ordered as the pair),
  `StatementCursor`, and `StatementLine::position()`.
  `LedgerStore::statement` takes a `StatementCursor`; `Cursor` still pages the
  log, where addressing an entry is right.

- **The reference implementation could not do what the storage trait could.**
  `prove_sealed_balance` landed on `LedgerStore` only, leaving `Journal` — which
  is the semantics a backend is *defined* to agree with — without it. It is now
  on both, and both call the same `SealedBalance::assemble`, so the recipe has
  one home rather than two that can drift. Same reasoning as
  `PeriodCalendar::check_sealable`.

- **A `SealedBalance` could not leave the process that built it.** Every part of
  it serialises — `Seal`, `BalanceProof`, `AccountBindingProof` — but the bundle
  did not, and the bundle is the thing an auditor is handed. It now derives
  serde under the `serde` feature. A seal edited on the wire still fails to
  deserialise at all, so a recipient who never calls `verify` cannot be fooled
  either.

- **`SealChain` did not notice a shrinking account registry.** Handles are dense
  positions and are never reissued, so a registry only grows; a seal committing
  to fewer bindings than its predecessor is one rebuilt from a truncated set,
  which renumbers the handles every earlier balance is keyed on. New
  `SealChainError::ShrunkenRegistry`. Tree-head monotonicity was already checked;
  this is its counterpart for the third root.

### Changed

- **`PeriodError` gained `NotClosing`, `SealedOutOfOrder` and
  `UnsealedPredecessor`;** `JournalError::PeriodNotClosing` and
  `JournalError::UnknownPeriod` are **removed**, as are the identical variants on
  `SqliteError` and `PostgresError`. Those were three copies of one rule that
  could drift apart — and did, in that none of them enforced ordering. They are
  now one `PeriodError` surfaced through the existing `Period(#[from] …)`
  variants. Match on `JournalError::Period(PeriodError::NotClosing { .. })`
  where you matched `JournalError::PeriodNotClosing { .. }`.

- **`SealedBalance` and `SealedBalanceError` live in `seal`, not `storage`.**
  They are seal artifacts — a seal plus two proofs — not persistence ones, and
  putting them in `storage` would have forced `Journal` to depend on the storage
  layer to offer the same operation. Re-exported from the crate root either way,
  so `doubleentry::SealedBalance` is unchanged.

- **`LedgerStore::Error` must now convert from `SealedBalanceError` and
  `AccountError`.** The bounds sit on the associated type rather than on
  `prove_sealed_balance`, because a `where` clause there made the method
  uncallable through a generic `S: LedgerStore<P>` — including from the
  conformance suite, which is the tell that it was the wrong place. Two
  `#[from]` variants on a `thiserror` enum satisfy it.

- **`SealChain::verify` is linear rather than quadratic.** The one-seal-per-period
  rule rescanned the whole prefix at every position, which is `O(n²)` in exactly
  the operation an auditor runs; the periods seen so far are now carried along.
  A ledger on daily periods passes 3,600 seals within a decade.

### Documentation

- `BalanceLimit::permits` claimed an overflow computing the net counts as a
  breach. It compares the gross totals directly and cannot overflow — the doc
  described an implementation that no longer existed.
- `closing_postings` now states that an account without an `AccountKind` is
  silently out of scope, since that is the way a close quietly does nothing.
- **A from-empty consistency proof is vacuous, and now says so.**
  `ConsistencyProof::is_vacuous` is new, and `verify` plus all four
  proof-building methods carry the warning. Every log extends the empty tree, so
  a proof taken at `old_size == 0` verifies against any root at the right size —
  correct mathematics, and a trap: an auditor who archived a head before the
  first entry gets `true` from a check that examined nothing, indistinguishable
  from a real verification. Documented at the call sites that build one, not only
  inside `verify`'s body where it was already noted.

## [0.4.0] — 2026-08-15

### ⚠️ Hashes and schema changed

A seal now commits to a **tree head** — a size *and* a root — everywhere it
previously committed to a bare root. That changes the seal preimage, so the
reference seal vector moved:

| Vector | before | after |
|---|---|---|
| Reference seal hash | `ab58bc1a…` | `7f6f0218…` |

The entry hash, the account-binding commitment and every Merkle constant are
**unchanged**. The `seals` table gains `trial_balance_size` and `accounts_size`;
apply `schema/sqlite.sql` or `schema/postgres.sql` as they now stand. Nothing is
published to crates.io, so there is no migration and no ledger in the world to
be compatible with.

### Changed

- **Proofs verify against a `TreeHead`, never a bare root.**
  `InclusionProof::verify` now takes `&TreeHead` in place of `&Hash`, and
  `ConsistencyProof::verify` takes two. There is deliberately no root-only form
  left to reach for.

  The reason is a real defect, not tidiness. `leaf_index` and `tree_size` *steer*
  the walk rather than being checked by it, and neighbouring pairs steer it
  identically — so against a bare root a genuine proof for leaf 1 of a two-leaf
  log is accepted **unchanged** as a proof for leaf 2 of three, a position that
  log does not have. Rewriting the index alone fails and rewriting it past
  `tree_size` is refused; it is rewriting both together that aliases. Consistency
  proofs alias the same way in `new_size`. No false claim about a real log
  follows — a root determines its own size — but a verifier reading the position
  back was reading a number the prover chose. Pinning the size to a head the
  verifier already trusts leaves exactly one labelling that verifies, and costs
  one integer comparison.
- **`Seal::trial_balance_root` → `Seal::trial_balance`** and
  **`Seal::accounts_root` → `Seal::accounts`**, both now `TreeHead`. A seal is
  what a `BalanceProof` and an `AccountBindingProof` are checked against, so it
  has to carry the half that was missing.
- **`AccountRegistry::commitment` returns `TreeHead`**, and
  **`trial_balance_root` is now `trial_balance_head`**. `TrialBalanceCommitment`
  gains `head()` beside `root()`.
- `BalanceProof::verify` and `AccountBindingProof::verify` take the
  corresponding head.

### Added

- **Proofs against an archived head.** `MerkleLog::inclusion_proof_at`,
  `consistency_proof_between`, and `head_at`, mirrored on `Journal` as
  `prove_inclusion_at` / `prove_consistency_between` / `head_at` and on
  `LedgerStore` as all three. An auditor archives a head and comes back later;
  the log has grown and its current root proves nothing about the head they
  hold, so a proof against the present log was no use to them. The general
  consistency form relates two archived heads without either party learning the
  log's present size.
- **`LedgerStore::head_at`** is an indexed row read on both SQL backends, not a
  replay: every entry already stores the root as of its own sequencing. The
  conformance suite checks every historical head against a rebuilt log, which is
  precisely where a stored column and a replay could drift apart.
- Iceberg snapshots carry `doubleentry.trial_balance_size` and
  `doubleentry.accounts_size`, so a reader working from the table alone has both
  halves of each sealed head.

### Tests

- Exhaustive **proof-deformation** coverage for both proof types: every sibling
  insertion, deletion, duplication, adjacent swap and truncation point over
  every leaf of every log shape up to 24, plus property tests. The suite
  previously altered a path *value* but never its *length*, so padding and
  truncation went unexercised on the verifier that rejects them.
- The `leaf_index >= tree_size` range guard is now covered. It is load-bearing:
  without it a genuine proof for leaf 0 of eight verifies while claiming index
  8, the surplus index bits shifting off the top of the walk.
- The `old_size > new_size` guard is now covered, and is load-bearing twice
  over: it refuses a log shown to shrink, and it stands between a `new_size` of
  zero and an underflow in a module that turns the checked-arithmetic lint off.
  Handing the two heads over in the wrong order reaches it.
- Equal-size consistency proofs are checked negatively as well as positively —
  with no path to walk, the equality of the two roots is the entire check.
- **Golden vectors for proof paths**, inclusion and consistency, at six
  `(index, size)` and six `(old, new)` pairs. Sibling ordering within a path can
  be changed without moving any root, which would invalidate every proof ever
  handed out while leaving the root vectors green. RFC 6962 publishes proof
  vectors for the same reason.

## [0.3.0] — 2026-08-14

### ⚠️ Hashes changed

Two of the crate's committed golden vectors moved. Any hash, seal or proof
produced by `0.2.0` is invalid under `0.3.0`, and there is no migration: the
crate is unreleased, so there are no ledgers in the world to be compatible with.

| Vector | `0.2.0` | `0.3.0` |
|---|---|---|
| Reference entry content hash | `f66e3336…` | `5bd373dc…` |
| Reference seal hash | `98cde30e…` | `ab58bc1a…` |

The Merkle constants — the empty root, the leaf hash, and the roots for known
tree sizes — are **unchanged**, so the log structure itself is untouched.

After 1.0 a change of this kind additionally means bumping the encoding version
in the domain tag, so old bytes can never be silently reinterpreted under a new
format.

### Added

- **`Seal::accounts_root`** — a third Merkle root, over the handle-to-account
  bindings in force when the period was sealed. A trial-balance leaf names its
  account by handle, so without this the handles float: re-registering the same
  paths in a different order would leave every seal and every balance proof
  verifying byte for byte while each balance quietly referred to a different
  account. Comes with `AccountRecord`, `AccountBindingProof`,
  `account_binding_leaf`, `AccountRegistry::{commitment, prove_binding, records,
  restore, from_records}`.
- **`BalanceProof` and `TrialBalanceCommitment`** — prove that one account held
  one balance under a seal, in `O(log n)`, disclosing nothing else.
  `BalanceProof::verify_naming` checks the balance *and* the account it belongs
  to against a single seal, which is what turns "handle `#7` held this" into
  "`Assets:Cash` held this" without handing over the chart of accounts.
- **`BalanceLimit`** on an account — `NoCreditBalance` for an asset that cannot
  be overdrawn, `NoDebitBalance` for a liability that cannot be drawn beyond
  what was funded. Checked when an entry is *recorded*, against the balance the
  whole entry would leave behind, per currency and per layer independently.
  Both SQL backends enforce it inside the append transaction — PostgreSQL takes
  a row lock on the constrained account — because a limit checked before the
  write reads a pre-image that two concurrent appends both see, each fitting it
  and together breaching it.
- **Date-based balance assertions** — `BalanceAssertion::on_date` and
  `Journal::balance_on_date` fold by booking date. A bank statement says "as at
  31 March", not "after 4 812 entries", and folding by date is what puts a
  late-arriving backdated entry in the period it economically belongs to.
- **`LedgerStore::{define_period, transition_period, periods}`** — the period
  calendar is store state. A calendar held only in the caller's memory comes
  back open after a restart and starts accepting postings into books that have
  already been committed to.
- **`SealChain::from_seals`** — rebuild and re-check a stored chain in one call.
  Seals read back from a table are rows, not evidence, until a chain has
  accepted them.
- `MerkleAccumulator::try_from_parts` and `MalformedAccumulator`, so a backend
  that persists only the perfect-subtree cover can prove the rows it read back
  are the rows it wrote.
- `Hash::digest` and `RESERVED_DOMAIN_PREFIX`, so a caller hashing its own
  source documents for `DocumentRef` uses the engine's domain-separated
  construction rather than inventing a bare SHA-256.
- Two conformance checks, bringing the executable storage contract to twenty:
  posting dimensions survive a round-trip, and balance limits are enforced.

### Changed

- **Balances take a prefix *size*, not an index.** `Journal::balance`,
  `Journal::trial_balance`, `LedgerStore::balance`, `LedgerStore::trial_balance`
  and `LedgerStore::balances` now take `Option<u64>` counting entries: `Some(0)`
  is the empty ledger, `None` is everything so far. It is the same number a
  `TreeHead` carries, so a balance and the root it belongs with are named the
  same way — and a "last index included" of zero could not express an empty
  prefix at all. Both SQL backends moved from `log_index <= n` to
  `log_index < size`.
- **`Checkpoint` lost its `through_index` field.** The tree head already carries
  `size`; it now does double duty, naming the prefix the balance covers *and*
  pinning the history that prefix belongs to. `Checkpoint::new` takes three
  arguments, `Checkpoint::size()` reports the prefix, and
  `CheckpointError::IndexOutOfRange` became `SizeOutOfRange`. Two fields that
  must agree are two fields that can disagree, and these did — see *Fixed*.
- **`BalanceAssertion::at` is an `AssertAt` enum**, replacing `Option<u64>`;
  `at_index(i)` becomes `over_prefix(size)`.
- **`SealChain::new` takes a `LedgerId`**, and no longer implements `Default`.
- **`LedgerStore::register_account` is an upsert.** Re-registering a handle
  updates its classification, open window and balance limit; the path at a
  handle stays immutable and rebinding one is refused. Mirrored by
  `AccountRegistry::restore`.
- `Journal` gained `define_period` / `transition_period` / `seal_period`,
  replacing direct calendar manipulation for the sealing path — the seal has to
  commit to the balances before the period's state changes, which a bare
  transition cannot do.
- `schema/postgres.sql` and `schema/sqlite.sql`: `accounts.balance_limit` and
  `seals.accounts_root` added, `checkpoints.through_index` dropped. The crate is
  unreleased, so the reference DDL changed in place rather than by migration.

### Fixed

- **A checkpoint taken over an empty journal broke as soon as anything was
  recorded.** `Checkpoint.through_index: None` meant "empty prefix", while
  `Journal::balance(key, None)` meant "the current balance" — the same `None`,
  opposite meanings. The checkpoint verified when taken and returned
  `BalanceMismatch` forever after. Removing the field removes the ambiguity.
- **Master-data changes could not be persisted.** `AccountRegistry::close`
  existed and `accounts.closed_on` existed, but `register_account` was
  `ON CONFLICT DO NOTHING` in both SQL backends — so closing an account in a
  durable ledger was silently a no-op.
- **A seal chain did not enforce its own ledger.** `ForeignLedger` was only
  raised when comparing a seal against a predecessor, so a chain of length one
  accepted a seal from any books at all — while the `LedgerId` sits inside the
  seal preimage precisely to prevent that. The chain now names the ledger it
  covers and checks the first seal as strictly as the last.
- **One period could be sealed twice in a chain**, giving two commitments to one
  period's closing balances with nothing saying which the books mean. Now
  `SealChainError::DuplicatePeriod`.

### Removed

- **The shipped dimension newtypes** `ActivityId`, `CostObjectId`, `PartyId` and
  `SegmentId`. Naming four axes in the library was a chart of accounts by
  another route, and had to be worked around by everyone whose fifth axis
  mattered. Use `Dimensions` with caller-named `Label` axes;
  `LedgerPolicy::requiring` is how you insist a posting carries one.

### Notes

A balance limit can refuse a **reversal**: undoing a funding entry withdraws
money the account may since have committed, and a limit constrains the resulting
balance, so it cannot make an exception for a correction. Reverse whatever
consumed the funding first, or lift the limit deliberately. The interaction was
found by the randomised simulation and is pinned by a test and a checked-in
proptest seed.

## [0.2.0] — 2026-08-01

### Fixed

- **An entry's `kind` was hashed but never stored.** `Entry::with_kind` existed
  in the engine and the label was folded into the content hash, but neither SQL
  schema had a column for it and neither backend wrote it. Because `get`
  rehydrates through `adopt_verified`, which recomputes the hash and refuses a
  mismatch, that did not under-report the field — it made every kinded entry
  unreadable after a round-trip. Added `entries.kind` to both reference schemas
  and the read/write path to both backends.

### Added

- `StatementLine::kind`, so a statement can be grouped or filtered by document
  type without a second lookup per line.
- A conformance check that `kind` survives a store round-trip, including under
  PostgreSQL's deferred sequencing mode — which is what would have caught the
  above.

### Changed

- `StatementLine` is no longer `Copy`, since it now carries a `Label`.

## [0.1.0] — 2026-07-28

Initial development tag: the balanced-by-construction entry, exact
scaled-integer money, the canonical encoding and domain-separated hashes, the
append-only Merkle log with inclusion and consistency proofs, period seals and
the seal chain, open-item clearing, closing entries, the `LedgerStore` contract
with its conformance suite, and the in-memory, SQLite, PostgreSQL and Iceberg
backends.

[0.4.0]: https://github.com/hupe1980/doubleentry/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/hupe1980/doubleentry/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/hupe1980/doubleentry/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/hupe1980/doubleentry/releases/tag/v0.1.0