# Order history log captures all state changes with timestamps

- **Pattern:** `ab-001135` (`ecommerce-order-management.order-lifecycle.history-log`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001135
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001135)

## Why it matters

Without a persistent order history log, you cannot answer the questions that matter most during a dispute or refund: When did the order ship? Who changed the status? Was the cancellation before or after fulfillment? The only surviving evidence is the current `status` field and a single `updatedAt` timestamp that gets overwritten on every subsequent change. CWE-778 (Insufficient Logging) and GDPR Art. 5(1)(f) both require that processing activities are traceable and reconstructable. An `updatedAt` column proves nothing — it's a cursor, not a log.

## Severity rationale

High because missing history makes it impossible to reconstruct the order timeline during disputes, refund requests, or compliance audits, and the gap can never be backfilled after the fact.

## Remediation

Create an `order_history` table and write to it atomically inside the same database transaction as every status update. Use `db.$transaction` to guarantee both writes succeed or neither does.

```ts
// prisma/schema.prisma addition:
// model OrderHistory {
//   id         String   @id @default(cuid())
//   orderId    String
//   fromStatus String?
//   toStatus   String
//   actorId    String?
//   note       String?
//   createdAt  DateTime @default(now())
//   order      Order    @relation(fields: [orderId], references: [id])
// }

// lib/orders/state-machine.ts
await db.$transaction([
  db.orders.update({ where: { id: orderId }, data: { status: newStatus } }),
  db.orderHistory.create({
    data: { orderId, fromStatus: order.status, toStatus: newStatus, actorId, note },
  }),
])
```

## Detection

- **ID:** `history-log`
- **Severity:** `high`
- **What to look for:** List all status-transition code paths identified in the `state-transitions` check. For each path, determine whether it also writes a history entry. Look for an order history or audit log mechanism: a separate `order_history` or `order_events` database table, an embedded `history` array field on the order document, or a generic audit log table. Count how many of the status-transition paths write history entries. Check whether history entries capture at least 3 of these fields: the previous status, the new status, a timestamp, and the actor. A history mechanism that covers fewer than 100% of transition paths must not pass.
- **Pass criteria:** Every order status change (100% of transition paths) creates a persistent history entry containing at minimum the new status and a timestamp. History entries are stored in a durable way (database table or document array) that survives application restarts. The history logging appears alongside all status transition code paths, not just a subset. Report the ratio even on pass: "N of N transition paths write history entries."
- **Fail criteria:** No order history tracking mechanism exists — only the current `order.status` field is updated with no record of what it was before. Or history logging exists but covers fewer than 100% of transition paths — called in the checkout confirmation handler but not in the admin status update handler, for example. An `updatedAt` timestamp alone does not count as pass — it gets overwritten on subsequent changes.
- **Skip (N/A) when:** The project uses an external order management platform (Shopify Admin API, Medusa) that natively handles all history logging with its own audit trail, and 0 status writes occur in application code.
- **Detail on fail:** Describe what exists and what is missing. Example: `"Order status updates are applied directly to the orders table with no history logging. No order_history table or equivalent exists in the schema. 0 of 4 transition paths write history entries."`
- **Remediation:** Create an `order_history` table (or `OrderHistory` model in `prisma/schema.prisma`) and write to it atomically with every status transition:

  ```ts
  // In your Prisma schema (prisma/schema.prisma):
  // model OrderHistory {
  //   id          String   @id @default(cuid())
  //   orderId     String
  //   fromStatus  String?
  //   toStatus    String
  //   note        String?
  //   actorId     String?
  //   createdAt   DateTime @default(now())
  //   order       Order    @relation(fields: [orderId], references: [id])
  // }

  // lib/orders/state-machine.ts
  export async function transitionOrder(
    orderId: string,
    newStatus: string,
    actorId?: string,
    note?: string
  ) {
    const order = await db.orders.findUniqueOrThrow({ where: { id: orderId } })
    // ... validate transition ...

    await db.$transaction([
      db.orders.update({
        where: { id: orderId },
        data: { status: newStatus, updatedAt: new Date() },
      }),
      db.orderHistory.create({
        data: {
          orderId,
          fromStatus: order.status,
          toStatus: newStatus,
          actorId,
          note,
        },
      }),
    ])
  }
  ```

  Using a database transaction ensures the status update and history entry are always written together, preventing partial writes.

## External references

- cwe CWE-778
- iso-25010 functional-correctness
- gdpr Art. 5(1)(f)

Taxons: observability, data-integrity

HTML version: https://auditbuffet.com/patterns/ab-001135
