Order history log captures all state changes with timestamps
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.
// 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-transitionscheck. For each path, determine whether it also writes a history entry. Look for an order history or audit log mechanism: a separateorder_historyororder_eventsdatabase table, an embeddedhistoryarray 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.statusfield 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. AnupdatedAttimestamp 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_historytable (orOrderHistorymodel inprisma/schema.prisma) and write to it atomically with every status transition:// 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 — Insufficient Logging
- iso-25010:2011 · functional-correctness — Functional Correctness
- gdpr · Art. 5(1)(f) — Integrity and confidentiality of personal data processing records
Taxons
History
- 2026-04-18·v1.0.0·Initial import from ecommerce-order-management·automated