package_log has no UPDATE statements. Zero callers that modify state. One caller total: the payment confirmation handler, writing a record of what just completed. That is an audit log. It was never a cart.

I found that a few hours into planning this phase of the production app’s tournament checkout overhaul. The design document had a decision labeled D1: reuse the existing cart. The premise behind D1 was that the cart machinery was mature and worth threading tournament packages through. I had written that decision before verifying whether the thing I wanted to reuse was actually what I thought it was.

It was nothing of the sort.

the wrong premise almost made it to code

The cart-model doc had been through several passes. I had traced real user flows before writing it, which sounds disciplined and was, mostly. Tracing flows is not the same as reading the schema. The flow diagrams showed a box labeled “cart” and I filled in the assumed implementation without checking.

The design had eight open decisions, D1 through D8. Questions like whether the cart should expire, whether package selection should be atomic, how the available-packages view stays in sync with what a user has in their cart. Real questions that needed answers before code. I worked through each in a Claude-assisted design-review session, settling and recording them in sequence.

D1 was the first one. Once I grepped package_log, the decision was made for me.

what the schema actually was

package_log is a write-once audit record. A row lands there when a payment succeeds and says: this user, this tournament, this package, this price, this timestamp. There is no draft state, no remove operation, no quantity field, no expiration. You cannot build a cart on top of a table that was never designed to hold provisional state.

The existing cart does exist. But it is tightly coupled to concepts specific to the other registration flow: membership slots, division assignments, proration logic. None of those have equivalents in tournament registration. Threading tournament packages through it would have meant abstracting away coupling that exists for real reasons, not accidental ones.

I had four sub-tickets already written for the reuse approach. I deleted all four.

greenfield: one open cart per user

With the reuse story gone, the shape of the problem simplified. Tournaments sell packages. A user selects packages and pays. Between selection and payment, there is a cart. The cart needs a clear owner, a state machine, and the ability to hold multiple line items. Nothing exotic.

I picked the greenfield option from the design: a new cart table and a cart_item table, with a database-enforced rule that a single user can have at most one open cart per tournament. State transitions are explicit columns, not implicit from join patterns.

The schema is flat. cart holds the user, the tournament, the state (open, checked_out, abandoned), and timestamps. cart_item holds a foreign key to the cart, the package ID, the price locked in at add-time, and a remove timestamp as a soft-delete, so the audit trail stays intact. The locked price matters because tournament package pricing can change between browse and checkout.

The first sub-phase was the schema plus a helper library wrapping the common operations: open a cart, add an item, remove an item, check out. One stored-procedure layer, not raw SQL scattered across application files.

not taking the legacy path down

That sub-phase also included a dual-write. The checkout path writes to both package_log and the new cart machinery during the transition window. This is the part that looks like extra work and is actually the only safe way to ship a schema change under a live system with no downtime window.

The rule I follow here: the old path cannot be the casualty of the new work. If the new code fails silently, the old path still runs. Dual-write makes that true. It is not elegant, but it is correct.

the ajax cart and page-load hydration

The second sub-phase was the user-facing surface. An AJAX handler handles add, remove, and get-cart calls from the browser, and the page-load hydration populates the package selection page when a returning user already has an open cart.

The restore/remove flow was the one I was most careful about. A user returns to the package selection page with an open cart. They see their previously selected packages checked. They remove one. They add another. The cart state at checkout must reflect exactly what the user last saw, not a race between two tab opens or a stale session variable.

I ran end-to-end verification on a staging environment with a test account, adding packages, removing packages, leaving and returning, verifying that the hydrated cart on re-entry matched the state I left. It did.

the productive move was the deletion

I wrote the four sub-tickets in about twenty minutes while building the work breakdown. I deleted them in about thirty seconds once the premise failed. The thirty seconds felt like backtracking, but they were not. Those tickets were premature structure on a wrong assumption, and structure that survives a failed premise becomes drag.

The fastest path through this kind of work is not choosing the right design on the first try. It is being willing to grep the table before you commit to a plan that depends on what the table is, then collapsing scope fast enough that the replacement still ships the same day.

Before you write your next reuse decision into a design doc, grep the table first. Check its write paths, meaning who actually calls UPDATE or DELETE on it, whether it has any state or status column, and how many callers touch it at all. If the answer to any of those is none or one, the table is not what you think it is, and you have just saved yourself the sub-tickets.