# The bug that won't reproduce

> A member can't borrow anything, and every test passes. The data that triggers the bug is in production, as a history nobody can rebuild by hand. With events, that history is a file: copy the stream into a fixture, and the bug report is a failing test.

*A post after the series [CQRS with effect-cqrs](https://effect-cqrs.dambox.ch/blog/), on its lending library. November 2026.*

![A librarian cuts a short strip from a long roll of loan cards and drops it into a small glass box on the workbench, next to a magnifying glass](https://effect-cqrs.dambox.ch/_astro/the-bug-that-wont-reproduce.Bq4JK0UI_15yKxf.webp)

The ticket says: *Carla can’t borrow anything. The website says she has reached her limit of three books. She has none.* The developer tries it: a new member, a loan, it works. Three loans, a fourth refused, correct. Every test is green. *Cannot reproduce*, and the ticket waits for more information that never comes.

Bugs like this depend on history, a sequence of things that happened to one record over weeks. Nobody can guess the sequence, and a database row doesn’t keep it. Every snippet here runs in [`examples/blog/wont-reproduce.test.ts`](https://github.com/davidnussio/effect-cqrs/blob/main/examples/blog/wont-reproduce.test.ts).

## The member

This member aggregate is a variant of the one in [the series](https://effect-cqrs.dambox.ch/blog/sagas/). At some point someone wanted members to see their cancelled loans on their page, so a cancelled loan is now kept, marked `cancelled`, instead of removed:

examples/blog/wont-reproduce.ts

```ts
const makeMember = (countsAgainstLimit: (loan: Loan) => boolean) =>
  Aggregate.make<MemberState>()({
    name: "member",
    events: MemberEvent,
    initial: { joined: false, loans: {} },
    projection: {
      MemberJoined: (state) => ({ ...state, joined: true }),
      LoanRequested: (state, { bookId, loanId }) => ({
        ...state,
        loans: { ...state.loans, [loanId]: { bookId, status: "requested" } }
      }),
      LoanConfirmed: (state, { loanId }) => setStatus(state, loanId, "active"),
      // Cancelled loans are kept, so the member's page can list them.
      LoanCancelled: (state, { loanId }) => setStatus(state, loanId, "cancelled")
    }
  })
    .command("join", {
      payload: Schema.Struct({}),
      handler: Effect.fnUntraced(function*(state, _, { aggregateId }) {
        if (state.joined) return yield* new MemberAlreadyJoined({ memberId: aggregateId })
        return MemberEvent.cases.MemberJoined.make({})
      })
    })
    .command("requestLoan", {
      payload: Schema.Struct({ loanId: Schema.String, bookId: Schema.String }),
      handler: Effect.fnUntraced(function*(state, { bookId, loanId }, { aggregateId }) {
        if (!state.joined) return yield* new MemberNotFound({ memberId: aggregateId })
        if (Object.values(state.loans).filter(countsAgainstLimit).length >= loanLimit) {
          return yield* new LoanLimitReached({ memberId: aggregateId, limit: loanLimit })
        }
        return MemberEvent.cases.LoanRequested.make({ loanId, bookId })
      })
    })
    // What the saga runs when the book is out.
    .command("cancelLoan", {
      payload: Schema.Struct({ loanId: Schema.String, reason: Schema.String }),
      handler: (state, payload) =>
        Effect.succeed(
          state.loans[payload.loanId]?.status === "requested" ? MemberEvent.cases.LoanCancelled.make(payload) : []
        )
    })


const setStatus = (state: MemberState, loanId: string, status: Loan["status"]): MemberState => {
  const loan = state.loans[loanId]
  return loan === undefined ? state : { ...state, loans: { ...state.loans, [loanId]: { ...loan, status } } }
}


// The bug: every loan the member ever had counts against the limit, cancelled ones too.
export const Member = makeMember(() => true)
```

The change looked harmless and the tests passed: none of them cancels a loan and then asks for another.

## Works for me

The developer’s test is the obvious one, and it passes:

examples/blog/wont-reproduce.test.ts

```ts
it.effect("lends to a member who just joined", () =>
  Fixture.make(Member)
    .givenEvents([MemberEvent.cases.MemberJoined.make({})])
    .when("requestLoan", { loanId: "loan-1", bookId: "dune" })
    .expectEvent("LoanRequested"))
```

What happened to Carla is not obvious. Over a few weeks she asked for three books that were out, and the saga cancelled each request. Then she asked for a fourth:

examples/blog/wont-reproduce.ts

```ts
yield* members.commands.join("carla", {})
for (const [loanId, bookId] of [["loan-1", "dune"], ["loan-2", "emma"], ["loan-3", "ulysses"]]) {
  yield* members.commands.requestLoan("carla", { loanId, bookId })
  yield* members.commands.cancelLoan("carla", { loanId, reason: "BookAlreadyLent" })
}


const refused = yield* Effect.flip(members.commands.requestLoan("carla", { loanId: "loan-4", bookId: "solaris" }))
// refused: LoanLimitReached { memberId: "carla", limit: 3 }, and Carla has no books
```

With a table, all the developer would find is a row with `open_loans = 3`, or three rows in a `loans` table with a status. How did they get there? In which order? Was the counter ever right? The row is the end of a story, and the story is gone.

## Copy the stream

With events, the story is the data. Carla’s stream can be exported as it is, as JSON, by a support script with read access:

examples/blog/wont-reproduce.ts

```ts
// The support script: Carla's stream, as JSON, for the bug report.
const history = yield* members.history("carla", { payload: true })
const encode = Schema.encodeSync(Schema.toCodecJson(MemberEvent))
const exported = JSON.stringify(history.map(({ payload }) => encode(payload!)), null, 2)
```

The result is short and readable, which is half the diagnosis already:

examples/blog/carla.json

```json
[
  { "_tag": "MemberJoined" },
  { "_tag": "LoanRequested", "loanId": "loan-1", "bookId": "dune" },
  { "_tag": "LoanCancelled", "loanId": "loan-1", "reason": "BookAlreadyLent" },
  { "_tag": "LoanRequested", "loanId": "loan-2", "bookId": "emma" },
  { "_tag": "LoanCancelled", "loanId": "loan-2", "reason": "BookAlreadyLent" },
  { "_tag": "LoanRequested", "loanId": "loan-3", "bookId": "ulysses" },
  { "_tag": "LoanCancelled", "loanId": "loan-3", "reason": "BookAlreadyLent" }
]
```

The file goes into the repository next to the test, and the test reads it:

examples/blog/wont-reproduce.test.ts

```ts
// Carla's stream, as the support script exported it.
const carla = decodeStream(readFileSync(join(import.meta.dirname, "carla.json"), "utf8"))
```

Caution

A stream can hold personal data: names, addresses, notes. Leave it out or replace it before it leaves production. With [personal data fields](https://effect-cqrs.dambox.ch/guides/personal-data/), `history` returns decrypted values, so the export script is the place to redact them.

## A failing test

A fixture runs a command against a given history, without a database, in milliseconds. Given Carla’s history:

examples/blog/wont-reproduce.test.ts

```ts
it.effect("refuses Carla, who has no books", () =>
  Effect.gen(function*() {
    const error = yield* Fixture.make(Member).givenEvents(carla).whenFails("requestLoan", {
      loanId: "loan-4",
      bookId: "solaris"
    })
    assert.strictEqual(error._tag, "LoanLimitReached")
  }))
```

It fails the same way production did. The bug report is now a test, and the cause is one `inspectState` away: three loans in `state.loans`, all `cancelled`, all counted by the limit check.

The fix counts only the loans that are open:

examples/blog/wont-reproduce.ts

```ts
// The fix: only loans that are open count.
export const FixedMember = makeMember((loan) => loan.status !== "cancelled")
```

And Carla’s history becomes a regression test that stays in the suite:

examples/blog/wont-reproduce.test.ts

```ts
it.effect("lends to Carla, whose earlier loans were all cancelled", () =>
  Fixture.make(FixedMember)
    .givenEvents(carla)
    .when("requestLoan", { loanId: "loan-4", bookId: "solaris" })
    .expectEvent("LoanRequested"))
```

## Carla’s account fixes itself

There is no data to repair. Carla’s stream says what happened, and it was right all along: she asked, the books were out, the requests were cancelled. The member’s state, the one the limit check reads, is folded from those events every time, so the moment the fix is deployed, her state counts zero open loans and her next request goes through. The same goes for every other member who hit it, whether or not they filed a ticket.

With an `open_loans` counter in a table, the fix would also need a migration to recompute the counter for everyone, from data that may not say what it should be.

## Rules of thumb

* **Ask for the aggregate id, not for steps to reproduce.** The stream *is* the steps.
* **Keep a support script that exports a stream as JSON**, encoded with the events’ own schema, and redacting personal data.
* **Turn every such bug into a fixture with the real history.** It’s the shortest reproduction there is, and it keeps the bug from coming back.
* **When a change adds a state, test the commands that read it.** Here a new status, `cancelled`, changed what the limit check counted, and no test asked for a loan after a cancellation.

## Next

[Tests, schema changes and production](https://effect-cqrs.dambox.ch/blog/tests-schema-changes-and-production/) in the series covers fixtures and invariant tests, and the [testing guide](https://effect-cqrs.dambox.ch/guides/testing/) covers `Fixture` in full.
