Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
# flashy-ledger

<!-- What is this repository, what does it deploy to, and what must an agent
not break? Two or three sentences from somebody who knows is worth more
than everything below, which is only what is true everywhere. -->
`@flashylabs/ledger` — an append-only, multi-asset settlement ledger, published
to npm under Apache-2.0. It is the estate's **verification layer**: the rules
flashynetwork.com checks the books against, published in the open so nobody
takes "verifiable" on faith. It ships as a **library, not a site** — there is no
`public/` build and no deploy target, which is why the estate fold-coverage
survey reads it as a record repo with no served URL rather than a gap. It is
consumed by `@flashylabs/ledger` in ClaimYour.Gold and flashy.gold. The default
branch is `main`.

_Not yet described. The house rules below are estate-wide and were synced
here; what makes this repository different is still to be written._
**The shape is the product, and it is ports-and-adapters with teeth.** `src/domain/`
is a pure core — it reads no database, calls no clock, generates no randomness —
and everything that touches storage sits behind one interface, `LedgerStore`
(`src/ports/store.ts`). That seam is the entire migration story: a chain-backed
store can implement it later and the rules above do not change, because the rules
never knew where entries were kept. `src/adapters/` holds the implementations
(in-memory, Mongo, the read-only `GoldLedgerReader`, and the `field-map` that
lets this package's rules sit on top of a collection it did not design).

**What an agent must not break** is enumerated and executable: the numbered
guarantees in [`docs/INVARIANTS.md`](docs/INVARIANTS.md), each mapped to the test
that proves it, with `tests/invariants.test.ts` failing the build if the doc and
the suite drift. In particular — **`LedgerStore` has no update and no delete**,
and that absence is the append-only invariant (I-1); correct a mistake with
`reverse()`, never a mutation. **The `hashEntry` field order is frozen** —
changing it invalidates every chain that exists, so it is fixed deliberately, not
derived from object keys. **Amounts are signed integer `Minor` units**, never a
float or a formatted string (I-2/I-3). **Identities are opaque and tenant-scoped**,
enforced in `post()` before anything is hashed (I-8). And **the domain stays
pure** — no adapter, port, I/O builtin or third-party import reaches into
`src/domain/` (I-9, guarded by eslint for clocks/randomness and by
`tests/domain-purity.test.ts` for the import graph). Run `npm run check`
(typecheck + lint + coverage) before pushing; the roadmap, which is North Star
beyond Phase 1, is [`docs/ROADMAP.md`](docs/ROADMAP.md).

<!-- estate:house-rules -->
<!-- Synced from flashyos/tools/estate-house-rules.mjs. Edit it there, not here.
Expand Down
20 changes: 20 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ tagged never succeeded.
| 0.6.3 | `d10f9dc` | ✅ v0.6.3 | **landed** (run 13) | publish succeeded; the mirror step failed after it, so the tag was skipped. Tagged 2026-09-05; run 17 then answered E409, confirming it is on the registry |
| 0.7.0 | `6af5ee1` | ❌ | never dispatched | "declare the civilization commodities"; superseded by 0.8.0 the same day |
| 0.8.0 | `e52704b` | ✅ v0.8.0 | **landed** (run 14) | same shape as 0.6.3. Tagged 2026-09-05; runs 15 and 16 both answered E409 |
| 1.0.0 | `cf4d9ba` | ⚠️ `v1.0.0` unbacked | ❌ **ENEEDAUTH** (not on the registry) | the major bump. `publish.yml` runs on 2026-09-25 built the 1.0.0 tarball (89 files, 311 kB) but every run failed at **Publish to npm** with `npm error code ENEEDAUTH` — never authenticated to `npm.pkg.github.com`. A `v1.0.0` git tag (`1a8aba65`) was pushed anyway, so it backs **no published release** — the one thing the rule below forbids. The tag is still a valid git ref, so a consumer can install 1.0.0 from it (`#v1.0.0` builds from source via `prepare`); it is just not on the registry. See the 2026-10-05 note |

**Confirmed 2026-09-05.** Pushing v0.6.3 and v0.8.0 fired the tag flow, and both
runs reached `npm publish` and stopped at **E409 Cannot publish over existing
Expand All @@ -80,6 +81,25 @@ were pushed on 2026-09-05 and each names a release that actually landed.
Every version this package has ever declared now has either a tag or a
recorded reason for not having one. The next release tags itself.

## 1.0.0 regressed the publish — measured 2026-10-05

The line above — "the workflow... can no longer fail a release" — did not hold
for 1.0.0, and saying so is the point of a measured trail. The three
`publish.yml` runs for `v1.0.0` on 2026-09-25 all failed, and not the way 0.8.0
did. 0.8.0 reached `npm publish` and was refused E409 (already present), which
proves the token authenticates. 1.0.0 got as far as building the tarball and
then failed at **Publish to npm** with `npm error code ENEEDAUTH` — "need auth…
requires you to be logged in to https://npm.pkg.github.com". It never
authenticated at all.

So the registry carries the record through **0.8.0**; `1.0.0` is a declared,
tagged version that was never published. Fixing it is an operator action on the
publish workflow's auth (the `setup-node` registry/token wiring for the publish
step), not a code change in this package — tracked for the owner. Until it
lands, a consumer adopting 1.0.0 pins the **git tag** (`git+https://github.com/FlashyLabs/flashy-ledger.git#v1.0.0`),
which installs from source via `prepare` and needs no registry; a consumer on
the registry stays at `^0.8.0`, the newest version actually published.

Optional, and unrelated to the trail: set **`NPM_TOKEN`** (read-write on
`@flashylabs`) to mirror releases to public npm, where a stranger can install
with no auth. GitHub Packages requires a token even for a public package.
Expand Down
63 changes: 63 additions & 0 deletions docs/INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,66 @@ guards the one consumer this package has. The surrogate is held to
message would be wrong — and
› *refuses a salt short enough to be brute-forced*.

## I-9 · The domain is pure and storage-agnostic

**Claim.** `src/domain/**` reads no database, calls no clock, and generates no
randomness. Its only dependencies are other domain modules and `node:crypto`
for hashing, which is deterministic. The dependency arrow points one way:
`index` and `ports` and `adapters` may import the domain; the domain imports
none of them.

**Why.** This is the sentence the README and the roadmap rest the whole package
on — it is *the entire reason the ledger can move onto a chain without its rules
changing*, and why the rules can be tested exhaustively with no database in
sight. Purity has two halves and they fail differently. A clock or a random
source in the domain makes a hash non-replayable, so a verifier recomputing it
later disagrees with the sealed value — the invariant that Phase 2 anchoring
depends on. A dependency pointing the wrong way — a domain module importing an
adapter or `node:fs` — is quieter still: it typechecks, it lints, it passes
every behavioural test, and the seam that lets storage be swapped is gone with
nothing red to say so.

**Enforced by.** Two guards for the two halves. The runtime half is eslint on
`src/domain/**`: `no-restricted-globals` bans `Date`, and `no-restricted-properties`
bans `Date.now` and `Math.random`, each with the message that points at the
fix (pass `occurredAt` in from the caller). The dependency half is
`domain-purity.test.ts`, which reads the real import graph with the compiler's
own `ts.preProcessFile` — never a regex, which would read an `import` inside a
comment the same as a real one — and refuses any specifier that is not a domain
sibling or `node:crypto`: an adapter, a port, an I/O builtin, or a third-party
package. The two are complementary: lint catches a global *used*, the test
catches a module *imported*, and neither alone is the invariant.

**Proved by.** `domain-purity.test.ts` › *imports no adapter and no port, so storage never leaks upward*,
› *imports no I/O builtin, only deterministic node:crypto*, and
› *takes no third-party dependency* — the last two run over a set the suite
first proves non-empty, so a walk that resolved nothing cannot pass them
vacuously. The runtime half shows up as determinism:
`ledger.test.ts` › *is pure: the same inputs always produce the same hash* and
`merkle.test.ts` › *is deterministic regardless of input order*.

## Coverage · the suite runs against every adapter, and that is derived

Every invariant above that names `conformance.test.ts` is run *against every
adapter* — the phrase does the work only if "every adapter" is a fact rather
than a hand-kept list that quietly stopped matching the package. A guarantee
proven against the in-memory reference and no one else is a guarantee about the
reference, not about the Mongo store a network actually runs.

So the population is **derived**. `tests/adapters.catalog.ts` declares every
writable `LedgerStore` the package ships; `conformance.test.ts` builds its
harnesses from that catalog (so the suite runs exactly those classes, Mongo
skipped without a database but never dropped from the count); and
`adapter-coverage.test.ts` checks the catalog against the package's real
exports, recognising a writable store by structure — an `append` method — so
the read-only `GoldLedgerReader` is excluded by the same fact that makes it
read-only. A new writable adapter that is exported and not cataloged, or
cataloged and not harnessed, fails the build rather than shipping with no
conformance behind it. This is the `pulse.yml` lesson applied locally: a check
is only as good as the population it is pointed at, and that population is the
part nobody re-reads, so it is computed rather than trusted.

**Proved by.** `conformance.test.ts` › *harnesses every writable store class the catalog declares, and no stranger*
and `adapter-coverage.test.ts` › *catalogs exactly the writable stores the package exports — no more, no fewer*,
› *excludes the read-only reader by structure, not by an allowlist*.

78 changes: 78 additions & 0 deletions docs/adr/0004-mongo-concurrency-and-atomicity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# ADR 0004 — Concurrency and atomicity live in the Mongo adapter, as indexes

**Status:** Accepted · 2026-10-04

## Context

ADR 0003 made the domain pure and put persistence behind `LedgerStore`. That
leaves the hard operational guarantees — a replay never becomes a second entry,
two concurrent appends never both build on one chain head, a transfer never
lands half — to the adapter. The domain cannot help here: it reads no database,
so it cannot see the race. The port (`src/ports/store.ts`) states the
guarantees; this ADR records how the Mongo adapter keeps them, because the
reasoning is not obvious from the code and the wrong fix (enforcing them in
application code) looks correct right up until two processes race.

## Decision

**The guarantees are indexes, not code.** `ensureIndexes()` creates two unique
indexes, and they — not the `findByIdempotencyKey` pre-check in `append()` — are
the real guard, because code in this file runs once per process and an index
runs on every write from every process forever.

- `uniq_tenant_idempotency` on `(tenantId, idempotencyKey)` makes a replay a
no-op even when two processes submit the same key in the same instant, and
scopes the key to the tenant so two networks deriving a similar key from
similar source events do not deduplicate against each other.
- `uniq_tenant_chain_head` on `(tenantId, identityId, assetId, previousHash)`
lets exactly one of two appends that read the same head actually land; the
loser gets a duplicate-key error. It needs no partial filter: `identityId` and
`assetId` are in the key, so "one entry with no predecessor" is scoped per
chain rather than one null across the collection.

**A duplicate-key error is disambiguated, not swallowed.** On `insertOne`
failing with code 11000, `append()` re-reads by idempotency key. A winner found
means this was a replay — return it, `deduplicated: true`. No winner means the
collision was on the chain head — a genuine conflict, thrown so the caller
re-reads state and retries. Collapsing those two into one outcome would turn a
lost update into a silent success.

**Legacy indexes are dropped by name, first.** The pre-0.2 indexes were globally
unique rather than tenant-scoped. A surviving `uniq_idempotency` would keep
rejecting a second tenant's legitimate write that the new index permits, so
creating the new indexes without dropping the old would look like a migration
and behave like none.

**A multi-entry append requires a session, and refuses without one.** A
transfer's two legs must both commit or neither, which a single-document
database cannot give for free. `appendAll` opens a transaction via a
`MongoClient`; constructed without one, it rejects any batch of more than one
entry rather than writing the first leg and failing on the second. A torn
transfer is worse than a refused one. (A single-entry batch and an empty batch
need no session and are allowed.)

## Consequences

Good: the guarantees hold under real concurrency, across processes, without a
lock the application has to remember to take. The same conformance suite that
the in-memory reference passes runs against this adapter unchanged (ADR 0003),
against real Mongo in CI (`MONGO_URL` set), so "behaves like the reference" is
proven, not asserted.

Costs: multi-entry atomicity needs a replica set, so a standalone `mongod`
cannot run transfers — which the adapter surfaces as a refusal, not a
half-write. Transactions carry retry semantics: `withTransaction` may re-run the
callback, so `appendAll` resets its per-attempt results rather than appending
across attempts.

## How it is proved

- The concurrency and atomicity behaviour: `tests/conformance.test.ts` —
idempotent replay, the chain-head race, tenant isolation, and transfer
atomicity, run against every adapter the catalog declares (see
`docs/INVARIANTS.md`, *Coverage*), with Mongo exercised in CI.
- The refusal without a client, which conformance does not reach because it
always passes one: `tests/mongo-adapter.test.ts` — a multi-entry append with
no client throws before touching the collection, and names the fix.
- The amount-encoding boundary that an adoption is most likely to relax:
`tests/field-map.test.ts`, *the amount guard* (invariant I-2).
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions tests/adapter-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import * as pkg from '../src/index.js'
import { WRITABLE_STORE_CLASSES, isWritableStoreClass } from './adapters.catalog.js'

/**
* The conformance population is the package's population — derived, not trusted.
*
* `conformance.test.ts` proves every adapter it is pointed at behaves like the
* reference. This proves it is pointed at every adapter there is. The gap it
* closes is the one this estate has hit before from the other side: a suite
* whose worth is silently decided by a hand-kept list that stopped matching
* what the package actually ships. A new writable `LedgerStore` exported without
* a catalog entry would ship with no conformance behind it and nothing red;
* this fails the build instead.
*
* It reads the real module exports, so it cannot drift from them, and it
* recognises a writable store by structure (an `append` method), so the
* read-only `GoldLedgerReader` is excluded by the same fact that makes it
* read-only rather than by a name on a list.
*/

/** Every exported value that is a writable store class, by export name. */
const exportedWritable = Object.entries(pkg)
.filter(([, value]) => isWritableStoreClass(value))
.map(([name, value]) => ({ name, value }))

describe('adapter coverage', () => {
it('finds the writable stores it is meant to be guarding', () => {
// Vacuity guard: a green check over an empty export set would read exactly
// like a clean one, which is the failure this whole file exists to prevent.
expect(exportedWritable.length).toBeGreaterThan(0)
})

it('catalogs exactly the writable stores the package exports — no more, no fewer', () => {
const exported = new Set(exportedWritable.map((e) => e.value as unknown))
const cataloged = new Set<unknown>(WRITABLE_STORE_CLASSES)

const missing = [...exported].filter((c) => !cataloged.has(c))
const stale = [...cataloged].filter((c) => !exported.has(c))

expect(
missing,
'a writable LedgerStore is exported but not in tests/adapters.catalog.ts, so conformance never runs against it',
).toEqual([])
expect(
stale,
'the catalog lists a class the package no longer exports as a writable store',
).toEqual([])
})

it('excludes the read-only reader by structure, not by an allowlist', () => {
// GoldLedgerReader is a real export and deliberately not a LedgerStore: it
// has no append, so it is neither detected as writable nor cataloged, and
// the exclusion needs no special-case.
expect('GoldLedgerReader' in pkg).toBe(true)
expect(isWritableStoreClass((pkg as Record<string, unknown>).GoldLedgerReader)).toBe(false)
expect(exportedWritable.map((e) => e.name)).not.toContain('GoldLedgerReader')
})
})
Loading
Loading