Skip to content

Decision Log: Item Identifiers

Tracks the design decisions for the Item Identifiers project: how standard identifiers, additional QR codes, and labels are modeled and stored on Item, and how the fuzzy /lookup and exact /by-code search endpoints are shaped and indexed. Round 1 was resolved interactively during the design session on 2026-07-22 and is transcribed here as the canonical record; Round 2 holds the questions raised for review and resolved during the same session — all decided.

#QuestionStatusDecisionRound
DQ-001Which standard identifier set?DecidedUnion: UPC, EAN, GTIN, ISBN, ASINR1
DQ-002Identifier field shape?DecidedFlat ItemIdentifiers value object, five optional shape-validated fieldsR1
DQ-003Storage for QRs and labels?Decidedjsonb arrays on the item rowR1
DQ-004/lookup API shape?DecidedPOST .../lookup?lookup=<s> with Query body; GET .../lookup/{page}?lookup=<s>R1
DQ-005/by-code scope and name?DecidedRenamed from /by-qr-code; matches QRs and standard identifiersR1
DQ-006GTIN normalization mechanism?DecidedQuery-time candidate expansion over indexed equality (no per-row computation)R1
DQ-007/lookup indexing strategy?DecidedGenerated search_text column + single GIN trigram indexR1
DQ-008Frontend phase-1 scope and Search-box parity?DecidedNew fields added to BFF SSRM accessors; phase 1 = BFF routes, types, barcode utilities; UI in phase 2R1
DQ-009Which fields feed search_text?DecidedCurated surface + left(notes, 256); card_notes_default excludedR2
DQ-010ISBN-10 vs ISBN-13 handling?DecidedBoth shapes accepted; 10↔13 conversion in CodeCandidatesR2
DQ-011/lookup ranking and sort interplay?DecidedRelevance first, caller sort as tiebreak, eid stableR2
DQ-012/by-code method and response shape?DecidedGET with code query parameter; plain array, cappedR2
DQ-013Lookup machinery location (operations vs common-module)?DecidedOperations-local, under cards.arda.operations.common.libR2
DQ-014Write-time validation strictness and uniqueness?Decided400 on invalid shape; no uniqueness constraintR2
DQ-015QR/label list constraints (dedupe, limits)?DecidedLabels ≤128, QRs ≤64, ≤256 chars/entry, dedupe, order preservedR2
DQ-016Performance target and verification method?Decidedp95 targets + EXPLAIN CI test + repeatable seeding scriptR2

Round 1: Design-Session Decisions (2026-07-22)

Section titled “Round 1: Design-Session Decisions (2026-07-22)”

Resolved interactively during the design session; transcribed for the record. Options are summarized; the trade-off discussion lives in the session and the Design.

Context: The brief listed UPC, EAN, GTIN, ASIN; the prototype’s Amazon search validates UPC-A, EAN-8/13, ISBN-10 and returns ASIN — ISBN present, GTIN absent.

OptionDescriptionTrade-offs
ABrief list: UPC, EAN, GTIN, ASINNo ISBN; diverges from prototype surface
BPrototype-aligned: UPC, EAN, ISBN, ASINNo GTIN superset field
CUnion: UPC, EAN, GTIN, ISBN, ASINMost flexible; slightly more schema surface

Recommendation: Option B — matches what the front end handles today.

Decision: Option C (union) — UPC, EAN, GTIN, ISBN, ASIN, all optional.

Applied to: Design § Structural Design; Goal § Scope A.


Context: Determines the API surface, validation model, and indexability of /by-code.

OptionDescriptionTrade-offs
AFlat ItemIdentifiers value object — five optional fields flattened to columns via the Component.Sub patternIdiomatic; each column indexable; adding a sixth namespace later requires a migration
BNamespaced list [{namespace, value}] as jsonb (mirrors prototype enrichment engine)Extensible without migration; foreign to the module’s flattening idiom; weaker typing; harder exact-match indexing

Recommendation: Option A.

Decision: Option A, with shape-only (no check-digit) validation unifying the prototype’s divergent validators.

Amendment (2026-07-22 review): each identifier kind is a distinct @JvmInline value class (Upc, Ean, Gtin, Isbn, Asin) with a smart constructor returning Result — never a naked String. General rule: domain-meaningful values always get a type alias or value class (added to the Kotlin coding standards and the front-end equivalent).

Applied to: Design § Class Diagram, § Key Classes.


DQ-003: Storage for Additional QRs and Labels?

Section titled “DQ-003: Storage for Additional QRs and Labels?”

Context: The item table is bitemporal; child tables multiply write and join complexity, while jsonb rides the version row.

OptionDescriptionTrade-offs
Ajsonb arrays on the item row (demand-module Exposed precedent)One write; exact containment via GIN jsonb_path_ops; fuzzy on labels via trigram over labels::text (approximate per-element ranking)
BBitemporal child tables (item_qr, item_label)Textbook per-column indexing; substantially more code; temporal parent–child joins with documented alias pitfalls
CDelimited string-encoded columnsCheapest; fragile exact matching via delimiter tricks

Recommendation: Option A.

Decision: Option A.

Applied to: Design § Structural Design, § Persistence.


Context: The brief proposed string + page number + page size, which conflicts with the module’s cursor-paged POST {resource}/query convention; lookup-items (typeahead) already exists with different semantics.

OptionDescriptionTrade-offs
AGET /lookup?string=&page=&size=Matches brief; breaks the query/cursor convention; second pagination model to maintain
BFollow /query shape: POST .../lookup?lookup=<s> with Query body (filter, sort, paginate); GET .../lookup/{page}?lookup=<s> for subsequent pagesConvention-consistent; composes filters with fuzzy matching

Recommendation: Option B.

Decision: Option B.

Applied to: Design § API Contract; Project Plan Phase 2.


Context: A physical UPC barcode is scannable like a QR-style code; restricting the route to the Additional QRs list would force clients to call two endpoints per scan.

OptionDescriptionTrade-offs
A/by-qr-code, Additional QRs onlyAs briefed; scan resolution needs a second call for barcodes
B/by-code, exact match against Additional QRs and any standard identifierOne scan endpoint resolves anything

Recommendation: Option B.

Decision: Option B — route named /by-code.

Applied to: Design § API Contract, § Behavioral Design.


Context: A scanner may deliver an EAN-13 rendition of a code stored as 12-digit UPC-A. Matching must bridge zero-padding families without hurting query performance (condition attached to the decision).

OptionDescriptionTrade-offs
AStore normalized GTIN-14 in a generated column; compare normalizedExtra columns and indexes; write-side complexity
BStore verbatim; expand the queried code into its ≤4 zero-padding equivalents at query time and OR indexed equalitiesNo schema growth; per-query cost is a handful of b-tree probes

Recommendation: Option B.

Decision: Option B, conditional on query performance not being compromised — satisfied by construction since expansion happens once per request, not per row.

Applied to: Design § Behavioral Design (/by-code flow), § Key Classes (CodeCandidates).


Context: Initially recommended per-column trigram indexes + OR (matching the existing lookup-* precedent). The decision to include the full Items-page search text surface (~20 text columns) flipped the economics: per-column GIN maintenance on a bitemporal table (full row re-insert per update) and a 20-way BitmapOr per query both degrade.

OptionDescriptionTrade-offs
APer-column trigram indexes, OR’d predicatesIdiomatic; acceptable at ~8 columns; poor at ~20 (write amplification, planner overhead)
BStored generated search_text column concatenating the searchable fields; single GIN gin_trgm_ops index; rank by word distanceOne index to maintain and scan; DB-owned consistency; wider row

Recommendation: Option B (revised from A after the field-set expansion).

Decision: Option B.

Applied to: Design § Persistence, § Behavioral Design (/lookup flow); Project Plan Phase 1 (migration tasks).


DQ-008: Frontend phase-1 scope and Search-box parity?

Section titled “DQ-008: Frontend phase-1 scope and Search-box parity?”

Context: The Items page Search box filters ~27 accessor fields in the BFF (SSRM route over a cached item set); UI enablement is deferred to phase 2.

Decision (recorded without alternatives — directed by the project owner): the new fields join the BFF SSRM search accessors so Search-box parity is preserved; phase 1 delivers BFF routes for /lookup and /by-code, TypeScript types and mappers, and shared barcode utilities extended to ASIN and GTIN. UI surfaces come in phase 2.

Applied to: Design § Implementation Scope (frontend); Project Plan Phase 3.


Context: The generated column defines exactly what /lookup can match. The Items-page Search box today matches ~27 accessors, including large free-text fields (notes, cardNotesDefault, up to 8 KiB each) and derived/enum values (costs, dates, print sizes) that are odd targets for server-side fuzzy text search.

OptionDescriptionTrade-offs
AFull parity: every text accessor the Search box matches, including notes and card_notes_defaultMaximal parity; large search_text values bloat the row and the GIN index; noise matches from long prose
BCurated surface: name, description, internal_sku, gl_code, use_case, classification type/subtype, the four locator fields, supplier/supply names and SKUs (both slots), manufacturer name, the five identifiers, labelsLean index; omits notes — a Search-box hit on notes would not be a /lookup hit
COption B + notes + card_notes_defaultFull text parity minus non-text accessors; medium index size; % set-similarity degrades on long documents
DOption B + truncated notes via left(notes, N) in the generation expressionBounds bloat and ranking noise while keeping the leading, identifying portion of notes searchable; left() is immutable so it is legal in a generated column

Recommendation: Option B originally; revised to Option D during the follow-up given the ssrm-direct-be-query trajectory (backend lookup should trend toward a superset of the Search box).

Decision: Option D, modifiedleft(coalesce(notes, ''), 256) included; card_notes_default excluded. Labels participate with their complete contents (untruncated; the DQ-015 per-entry bound is what limits them).

Applied to:

  • Design § Persistence and Migrations (generation expression), § Key Classes
  • Project Plan Phase 1, T-05

Context: The prototype validates ISBN-10 only. ISBN-13s are EAN-13s (978/979 prefix); ISBN-10 ↔ ISBN-13 conversion requires check-digit recomputation, which DQ-002 excluded from phase 1.

OptionDescriptionTrade-offs
ASingle isbn field accepting both ISBN-10 and ISBN-13 shapes; no cross-form conversion in /by-code expansionSimple; a scan of the ISBN-13 form will not find an item stored as ISBN-10 (and vice versa)
BAccept both shapes and add 10↔13 conversion (with check-digit computation) to CodeCandidatesComplete matching; ~15-line pure function; the physical-book barcode is always the EAN-13 form, so without conversion the common scan case misses

Recommendation: Option A originally; revised to Option B during the follow-up — the cost is a contained pure function, and the miss case (scan 978… vs stored ISBN-10) is the common one for books. Write-time validation stays shape-only per DQ-002; the check-digit computation lives only inside the conversion.

Decision: Option B. Conversion is one-directional for 979… ISBN-13s (no ISBN-10 equivalent exists).

Applied to:

  • Design § Key Classes (ItemIdentifiers, CodeCandidates), § Behavioral Design
  • Project Plan Phase 1, T-02

DQ-011: /lookup ranking and sort interplay?

Section titled “DQ-011: /lookup ranking and sort interplay?”

Context: The Query body carries an optional sort; fuzzy search has an intrinsic relevance order (trigram word distance).

OptionDescriptionTrade-offs
ARelevance first: order by search_text <->> lookup, then the caller’s sort, then eid as stable tiebreakBest default UX; caller sort only breaks ties
BCaller’s sort wins when present; relevance only as defaultPredictable for grid-style consumers; can bury the best matches

Recommendation: Option A — /lookup is relevance-oriented by definition; grid-style listing already has /query.

Decision: Option A.

Applied to:

  • Design § Key Classes (ItemUniverse), § API Contract

DQ-012: /by-code method and response shape?

Section titled “DQ-012: /by-code method and response shape?”

Context: Exact-match resolution of one code; expected cardinality 0–few (identifiers are not enforced unique — see DQ-014).

OptionDescriptionTrade-offs
AGET .../by-code?code=<c> returning a plain JSON array of full item records (no pagination)Simple, cache-friendly, matches read semantics; unbounded in pathological data
BPOST .../by-code returning a PageResultConvention-heavy for a 0–few result set; consistent envelope

Recommendation: Option A, with a documented server-side cap (e.g. 50) as a safety bound.

Decision: Option A — the code text travels as the code query parameter; server-side cap of 50 (configurable).

Applied to:

  • Design § API Contract (/by-code), § Behavioral Design

Context: The trigram operators (%, <->>) live in common-module; the generated-column lookup and the jsonb containment operator are new. The project’s declared repos are operations, arda-frontend-app, documentation.

OptionDescriptionTrade-offs
AOperations-local: ItemUniverse implements lookupPage/byCode with module-level Exposed expressions (jsonb @> operator defined in operations)No common-module release in the critical path; second consumer would duplicate
BGeneralize into common-module AbstractUniverse nowReusable; adds a common-module release + version-catalog bump + longer merge chain

Recommendation: Option A — promote to common-module when a second module needs it.

Decision: Option A, modified — the reusable pieces (jsonb containment operator, any generic lookup helpers) live under the operations-wide cards.arda.operations.common.lib package, not inside reference/item, to mark a clean separation and ease later promotion to common-module.

Applied to:

  • Design § Key Classes (ItemUniverse), § Files to Create

DQ-014: Write-time validation strictness and uniqueness?

Section titled “DQ-014: Write-time validation strictness and uniqueness?”

Context: Identifier fields are shape-validated (DQ-002). Two orthogonal choices: what happens on invalid input, and whether identifiers must be unique per tenant. The brief’s “return a Page of items” for /by-code implies duplicates are legal.

OptionDescriptionTrade-offs
AReject invalid shapes with a 400 (smart-constructor Result per module convention); no uniqueness constraintConsistent error channel; duplicate identifiers across items allowed — /by-code may return several items
BAccept any string, no validationGarbage accumulates; /by-code normalization becomes unreliable
COption A + per-tenant uniqueness on each identifierStrong integrity; conflicts with legitimate cases (same UPC stocked as two internal items) and complicates bitemporal writes

Recommendation: Option A.

Decision: Option A.

Applied to:

  • Design § Key Classes (ItemIdentifiers), § Testing Strategy

Context: Unbounded lists on a bitemporal row multiply storage per version and affect the search_text width.

OptionDescriptionTrade-offs
AServer-enforced bounds: ≤64 entries per list, ≤256 chars per entry, exact-duplicate entries rejected, order preservedPredictable rows and indexes; limits are generous for the use case
BUnbounded, duplicates allowedNo arbitrary limits; pathological rows possible

Recommendation: Option A (limits configurable in module config, asserted from config in tests). Order preservation rationale (follow-up): jsonb arrays make it free, round-trip fidelity matches user expectations, and it keeps a “first QR is primary” phase-2 semantic open; the cost — a pure reorder writes a new bitemporal version — is defensible behavior, and matching is order-independent either way.

Decision: Option A, modified — labels ≤ 128 entries, additional QRs ≤ 64 entries, ≤ 256 chars per entry, exact duplicates rejected, order preserved; limits configurable, tests assert from config.

Applied to:


DQ-016: Performance target and verification method?

Section titled “DQ-016: Performance target and verification method?”

Context: “Very performant” needs a number and a way to verify it without flaky wall-clock assertions in CI.

OptionDescriptionTrade-offs
ATarget p95 ≤ 100 ms for /lookup and ≤ 20 ms for /by-code at 50k items/tenant; verify via an EXPLAIN-based integration test asserting index usage (no seq scan) plus a non-CI seeded benchmark scriptDeterministic CI; timing evidence gathered manually per release
BWall-clock assertions in CI against a seeded containerDirect evidence; notoriously flaky in shared CI runners

Recommendation: Option A.

Decision: Option A, modified — the manual seeding is performed by a checked-in test script so the benchmark is easily repeatable.

Applied to:


Owner review of the committed design surfaced four corrections, applied before implementation started:

  1. Missing Service layer (structural) — the class diagram omitted ItemEndpoint and ItemService. Corrected: the diagram now shows all four layers of the Data Authority pattern.
  2. Typed domain values — identifier fields were drawn as String?. Corrected per the general rule (never naked base types for domain values): value classes Upc/Ean/Gtin/Isbn/Asin (DQ-002 amendment); the rule itself was added to the Kotlin coding standards page and the React component design page.
  3. Missing Service layer (behavioral, /lookup) — the sequence went endpoint → universe. Corrected: ItemService.lookupItems coordinates the read and establishes the inTransaction(db) boundary.
  4. Business logic in the endpoint (/by-code) — the endpoint called CodeCandidates.expand. Corrected: expansion, the result cap, and the transaction boundary live in ItemService.findByCode; the endpoint validates the request and formats the response only.


Copyright: (c) Arda Systems 2025-2026, All rights reserved