Skip to main content

ADR-0024 — Serial numbers as first-class entities

  • Status: Accepted
  • Date: 2026-05-11
  • Decision-makers: Tom Anderson

Context​

Three planned roadmap slices share an underlying need:

  • Slice 15 — E-bike serialization and battery lifecycle wants per-battery cycle counts, firmware versions, and warranty periods that are independent of the parent bike's
  • Slice 16 — Bike registration integration needs a stable identifier for the bike across registry pushes, ownership transfers, and theft reports
  • Slice 17 — Warranty claim workflow needs to assemble the full event history for a serialized item: when bought, when serviced, when failed, by whom

Today the schema treats serial numbers as a text column on inventory_skus (and a redundant copy on customer_bikes). That works for the simple case ("this bike's serial is X") but doesn't support the multi-event lifecycle the three new slices need:

purchase → registration → service history → warranty claim → trade-in → resale → next owner

The serial number is the through-line across all of those events. Modeling it as a text field means re-deriving its history through joins every query, and means events that should reference "this specific physical object" have no consistent foreign key to use.

Batteries make this worse: they work the same way as bikes but with their own attributes (cycle count, charge history, firmware version, warranty period independent of the bike's). A bike can outlive its first battery; the second battery is a different object with a different lifecycle.

Options considered​

Option 1 — Keep as text column, re-derive on each query. Cheapest near term. Cost compounds over time: every new slice that wants serialized-object history reinvents the join. Cross-event indexing is impossible (the text column isn't a real identity). Warranty workflow becomes prohibitively manual because the assembly of "everything we know about serial X" can't be cached.

Option 2 — Promote to a first-class entity with its own table. A serialized_objects table holds one row per physical tracked item; events reference it by id. Migration of existing inventory data is non-trivial but bounded; new events compose cleanly.

Option 3 — Promote selectively, only for items the shop chooses to track. A compromise where shops opt items in. Adds an opt-in dimension that quickly becomes its own complexity surface (which event types respect the opt-in? what happens when an opted-out item gets a warranty claim?). The semantics of "tracked vs untracked" pollute every downstream feature.

Decision​

Promote serial numbers to a first-class entity. Before any of slices 15–17 builds, land the primitive.

Schema shape (engineer to refine the exact column set when the slice 12+ prep migration is written):

CREATE TABLE serialized_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_type TEXT NOT NULL, -- 'bike' | 'battery' | 'motor' | 'other'
serial_number TEXT NOT NULL,
manufacturer_id INTEGER REFERENCES manufacturers(id),
model_reference TEXT, -- the SKU or model identifier
current_owner_customer_id INTEGER REFERENCES customers(id),
current_status TEXT NOT NULL, -- 'in_stock' | 'sold' | 'in_service' | 'warranty_claim' | 'returned' | 'retired'
registration_id TEXT, -- e.g., Project529 / Bike Index id; NULL until slice 16 registers
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_event_at TEXT,
UNIQUE (object_type, serial_number)
);

CREATE TABLE serialized_object_relationships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NOT NULL REFERENCES serialized_objects(id),
child_id INTEGER NOT NULL REFERENCES serialized_objects(id),
relationship_type TEXT NOT NULL, -- 'battery_of_bike' | 'motor_of_bike' | 'spare_for' | etc.
attached_at TEXT NOT NULL DEFAULT (datetime('now')),
detached_at TEXT
);

Lifecycle events (purchase, registration, service, warranty, trade-in, resale) reference serialized_objects.id, not the serial-number text. Batteries and motors are their own rows in serialized_objects, linked to a parent bike row via serialized_object_relationships.

Migration of existing data:

  • For every row in inventory_skus with a non-null serial number that's been sold (i.e., has a transaction line referencing it), generate a serialized_objects row of object_type='bike'
  • Same for customer_bikes (where the serial number is set)
  • The text columns on the existing tables remain as denormalized convenience columns for now; new event types use the serialized_objects.id foreign key
  • Eventual removal of the text columns is a separate cleanup; not blocking for slices 15–17

Consequences​

Positive:

  • Slices 15 / 16 / 17 build on a coherent primitive instead of three different join shapes
  • Cross-event queries become indexable: "show me everything we know about serial X" is one PK lookup + N event-table joins, all indexed
  • Future features compose on the same primitive: recalls, theft recovery, multi-owner ownership history, depreciation tracking, fleet-asset auditing
  • Battery as its own object correctly models the lifecycle (a bike outliving its first battery)
  • Stays consistent with audit-everything — every event still writes through recordMutation

Negative:

  • Existing inventory data with serial numbers in text columns needs migration. Bounded (~9,338 variants for Swicked, fewer with serials), but not trivial.
  • The denormalized text columns remaining after migration are a transitional smell. They must be removed eventually or they become a permanent inconsistency surface.
  • One more concept ("serialized object") for new readers of the schema to learn.

Mitigations:

  • The migration runs as Stage 3 prep, not as part of any single slice. Lives in a numbered migration file with full row-by-row provenance.
  • Existing rows get a serialized_objects record auto-generated by the migration script; manual intervention only for ambiguous cases (duplicates, missing serials).
  • The text columns stay temporarily for read-side convenience but new write code uses the foreign key; a tracked tech-debt item flags the removal task.
  • The "serialized object" concept gets its own entity page (analogous to existing entity pages) when the slice lands, so new readers have one place to learn it.

Notes​

This ADR captures the decision. The implementation (exact column set, migration script, audit-event vocabulary, UI surface) is part of the Stage 3 prep work — not this consolidation pass. The point of pinning the decision now is so the three Stage 3 slices can be specified consistently when their turn comes.

See also​