Skip to content
Merged
38 changes: 31 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
An append-only, multi-asset, storage-agnostic settlement ledger, published as
a TypeScript library (ESM and CJS builds, no runtime dependencies; `mongodb`
is an optional peer). Flashy Gold is the first asset on it, not the thing
itself. Consumed by flashy-rails (consent-gated settlement on top of it) and
ClaimYour.Gold (hash chains computed here, written through Prisma). There is
no deployed website: what ships is a package, and as of 2026-09-28 it is
**not yet on the public npm registry** (see Publishing).
itself. Consumed by flashy-rails (consent-gated settlement on top of it),
ClaimYour.Gold (hash chains computed here, written through Prisma, and the
nightly Merkle anchor) and flashy.gold (read-only, through `GoldLedgerReader`).
There is no deployed website: what ships is a package — which is why the
estate fold-coverage survey reads this as a record repository with no served
URL rather than a gap — and as of 2026-09-28 it is **not yet on the public npm
registry** (see Publishing).

## Commands

Expand Down Expand Up @@ -51,7 +54,23 @@ reaches for `Date` or `Math.random()`. `src/ports/store.ts` is the seam;
specification, because `tests/conformance.test.ts` runs the same suite
against it and against `MongoLedgerStore`. A backing store behaves
identically or fails the build. The dependency rule is one-directional:
adapters → ports → domain → nothing.
adapters → ports → domain → nothing — and that direction is enforced, not
only stated: `tests/domain-purity.test.ts` reads the real import graph of
`src/domain` with the compiler's own `ts.preProcessFile` and refuses any
specifier that is not a domain sibling or `node:crypto` (I-10). Lint catches a
global *used*; the test catches a module *imported*; neither alone is the
invariant.

**The conformance population is derived, never hand-kept.**
`tests/adapters.catalog.ts` is the one list of writable `LedgerStore` classes;
`conformance.test.ts` builds its harnesses from it (Mongo skipped without a
database, never dropped from the count) and `tests/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. An exported store that
is not harnessed fails the build. The Mongo adapter's concurrency and
atomicity design — why a transfer needs a client, what the two unique indexes
refuse — is written down in `docs/adr/0004-mongo-concurrency-and-atomicity.md`.

**`Minor` is a branded integer and refuses to be anything else.**
`src/domain/money.ts`: `minor()` throws `PrecisionError` on a non-integer or
Expand Down Expand Up @@ -90,8 +109,13 @@ drift from the TypeScript source.

## Rules the tests enforce — none decorative

- `docs/INVARIANTS.md` numbers eight invariants, each citing a test by name;
`tests/invariants.test.ts` fails if a cited test no longer exists.
- `docs/INVARIANTS.md` numbers ten invariants, each citing a test by name;
`tests/invariants.test.ts` fails if a cited test no longer exists, if the
numbering has a gap, or if a section is missing one of its four headings.
- `tests/domain-purity.test.ts`: nothing under `src/domain` imports an
adapter, a port, an I/O builtin or a third-party package (I-10).
- `tests/adapter-coverage.test.ts`: every exported writable store is in the
conformance catalog and every cataloged store is exported.
- `tests/public-api.test.ts` lists every export by hand. Removing one is a
major version, and the list is deliberately not a generated snapshot.
- `tests/workflow-pins.test.ts`: every third-party `uses:` in a workflow is
Expand Down
23 changes: 21 additions & 2 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +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 | `eac50d8` | ✅ v1.0.0 | assumed (GitHub Packages, unverified from this checkout); **not** on registry.npmjs.org — `npm view` answered 404 on 2026-09-28 | "Bump to 1.0.0 for npm publication". The bump commit forgot this row, so `releases.test.ts` was red. `origin`'s `v1.0.0` points at `1a8aba6`, a commit this checkout does not hold, while a local tag of the same name points at `eac50d8` — two tags, one name; read `git ls-remote --tags origin` before trusting either. The 1.0.0 tree now carries four fixes our own interleaving audit found behind the green suite, so the publish that finally lands on npmjs will have them: `post`/`postTransfer` re-assert the `Minor` brand at runtime (a JSON body can no longer hash a NaN, a fraction or a string into a chain); the reference `InMemoryLedgerStore.appendAll` guards the chain head, so two concurrent appends cannot both land and fork it (invariant I-9, one head per chain); and a transfer from an identity to itself is refused (`SELF_TRANSFER`) rather than forking the chain and minting. The `invariants/1` harness from spec-kit is vendored and drives these under concurrency with a mutation check each |
| 1.0.0 | `eac50d8` | ⚠️ `v1.0.0` unbacked | ❌ **ENEEDAUTH** (not on either registry) | "Bump to 1.0.0 for npm publication". The bump commit forgot this row, so `releases.test.ts` was red. The three `publish.yml` runs on 2026-09-25 built the 1.0.0 tarball (89 files, 311 kB) and every one failed at **Publish to npm** with `npm error code ENEEDAUTH` — never authenticated to `npm.pkg.github.com`, the target at the time. `npm view` on registry.npmjs.org answered 404 on 2026-09-28. `origin`'s `v1.0.0` tag points at `1a8aba6` ("Allow test failures for now"), so it backs **no published release** — the one thing the rule below forbids; a local tag of the same name has pointed at `eac50d8` — two tags, one name; read `git ls-remote --tags origin` before trusting either. The tag is still a valid git ref, so a consumer can install from it (`#v1.0.0` builds via `prepare`); it is just not on a registry. See the 2026-10-05 note. The 1.0.0 tree now carries four fixes our own interleaving audit found behind the green suite, so the publish that finally lands on npmjs will have them: `post`/`postTransfer` re-assert the `Minor` brand at runtime (a JSON body can no longer hash a NaN, a fraction or a string into a chain); the reference `InMemoryLedgerStore.appendAll` guards the chain head, so two concurrent appends cannot both land and fork it (invariant I-9, one head per chain); and a transfer from an identity to itself is refused (`SELF_TRANSFER`) rather than forking the chain and minting. The `invariants/1` harness from spec-kit is vendored and drives these under concurrency with a mutation check each |

**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 @@ -81,12 +81,31 @@ 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 of the day authenticated. 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 record stops at **0.8.0**; `1.0.0` is a declared, tagged
version that was never published anywhere. Until a publish lands, a consumer
adopting 1.0.0 pins the **git tag**, which installs from source via `prepare`
and needs no registry; a consumer on a registry stays at `^0.8.0`, the newest
version actually published. flashy.gold pins `#v0.8.0` for exactly this reason.

**Since 2026-09-28 the public npm registry is the target, not a mirror.**
`publishConfig` and `publish.yml` point at `registry.npmjs.org`; the workflow
refuses to run without **`NPM_TOKEN`** (publish rights on `@flashylabs`)
rather than landing anywhere else. GitHub Packages, which requires a token
even for a public package, keeps the versions above and receives no new ones.
Nothing is on the public registry yet — README, "Publishing".
Nothing is on the public registry yet — README, "Publishing". What remains is
an owner action, not a code change: set `NPM_TOKEN` and dispatch `Publish`;
the next landed row here is the measurement that it worked.

## Adding a release

Expand Down
63 changes: 63 additions & 0 deletions docs/INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,66 @@ time. The same store under random sequences is refused by
the port's §4 case — a caller that did not re-read state — reduced to three
commands.

## I-10 · 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).
Loading
Loading