Firearms Taxonomy - Plan#
Goal Capsule#
- Objective: Add a controlled
type/actionclassification plus a free-textsubtypeto each firearm so owners can group, filter, and report on their collection consistently instead of relying on free text. - Product authority: The firearm owner (owner-scoped inventory). Origin: GitHub issue #17.
- Open blockers: None. Ready for planning.
Product Contract#
Summary#
Classify each firearm with a controlled type and action, plus an optional free-text subtype, both required on every write and backfilled to unspecified for existing rows. Surface the categories on the firearm list and let owners filter the list by type. caliber stays free text.
Problem Frame#
Today a firearm records only free-text caliber and manufacturer with no classification. Owners can't group or filter their collection by category, and inconsistent free-text values fragment the data. The pain is organizational: as a collection grows, "show me my pistols" or "show me my bolt guns" is impossible, and rollups per owner are noisy. A small, controlled taxonomy fixes the grouping and consistency problem directly, and incidentally lays groundwork for later platform-aware features — but consistency and organization are the reason to build it now, not the downstream features.
Key Decisions#
- Fixed, code-defined category sets — no user-defined categories. The allowed
type/actionvalues are a controlled set enforced in the domain layer with a DB constraint as backstop (mirroring the existing magazine/grant check-constraint pattern). The lists evolve through migrations, not a UI. Rationale: the driver is consistency, and user-editable category lists would re-introduce the fragmentation the taxonomy exists to remove. A lookup table is deferred until a real need for user-defined categories appears (KISS/YAGNI). actionmodels the operating mechanism only. Trigger / fire-control details (striker-fired, DA/SA, SAO) do not live inaction— they go in free-textsubtypewhen the owner cares. This keepsactionorthogonal totypeand avoids the "a Glock is both semi-auto and striker" collision that a single flat mixed list forces.typeandactionare required on every write (create and edit); existing rows backfill tounspecified.unspecifiedis a valid stored value so backfilled rows pass the DB constraint, but the domain layer rejectsunspecified(and empty) on write, forcing a real selection. This means editing any legacy firearm forces classifying it at that moment — an accepted, deliberate forced-cleanup behavior that serves the consistency goal.- Lean three-field spine only (
type,action,subtype). The broader forensic classification axes (bore, loading mechanism, use) are rejected as controlled fields: each extra controlled axis would sit atunspecifiedon most rows, which works against the consistency goal.subtypeabsorbs any edge classification ad hoc. subtypeis free text (empty-not-null, R18) with datalist suggestions, reusing the same UX as the existingcaliber/manufacturerinputs.
Requirements#
Data model
- R1. Each
firearmcarries atypedrawn from the controlled set:pistol,revolver,rifle,shotgun,pcc,other,unspecified. - R2. Each
firearmcarries anactiondrawn from the controlled set:semi-auto,bolt,lever,pump,break,single-shot,unspecified. - R3. Each
firearmcarries an optionalsubtypefree-text field (empty-not-null default'', per R18). - R4. The database enforces the allowed
typeandactionsets as a backstop (check-constraint style, matching the magazine/grant precedent); domain validation is the primary enforcement surface. - R5.
caliberremains free text and unchanged.
Validation
- R6. Create and edit reject any
typeoractionvalue outside its allowed set. - R7. Create and edit reject
unspecified(and empty) fortypeandaction— a real category must be chosen on every write, including when editing a previously-backfilled row. - R8. Validation returns all failing codes together (R20 convention) with a clear, field-specific message for each.
UI and filtering
- R9. The firearm create/edit form exposes
typeandactionselectors (ARIA-labeled by accessible name, nodata-testid) and asubtypefree-text input with suggestions. - R10. The firearm row and/or detail view shows the firearm's
type(andactionwhere layout allows). - R11. The inventory list can be filtered by
type.
Migration and backfill
- R12. A migration adds the columns and backfills every existing
firearmtotype = unspecified,action = unspecified, andsubtype = '', leaving no row invalid under the new constraints. - R13. All classification stays owner-scoped with no cross-owner leakage (unchanged invariant).
Testing
- R14. Integration coverage for the new fields: accepted values, rejected out-of-set values, required-on-write (including edit of a backfilled row), and post-backfill validity. Testcontainers-backed.
- R15. E2E coverage for the taxonomy selectors on the form and for filtering the list by
type. Testcontainers-backed per the e2e harness.
Acceptance Examples#
- AE1. Covers R7.
- Given a legacy firearm with
type = unspecified(backfilled), - When the owner opens the edit form and saves without choosing a real
type, - Then the save is rejected with a message prompting a
typeselection, and nothing persists.
- Given a legacy firearm with
- AE2. Covers R7.
- Given the create form with no
type/actionchosen, - When the owner submits,
- Then the submit is rejected before persistence with per-field messages.
- Given the create form with no
- AE3. Covers R6.
- Given a submission carrying a
typeoractionvalue outside the allowed set (e.g. via a crafted request), - When it reaches the domain and DB layers,
- Then both reject it.
- Given a submission carrying a
- AE4. Covers R11, R13.
- Given an owner with firearms of mixed types,
- When they filter the list by
type = pistol, - Then only their own pistols are shown (owner-scoped).
- AE5. Covers R12.
- Given existing firearms created before this feature,
- When the migration runs,
- Then every row has
type = unspecified,action = unspecified,subtype = '', and passes the new DB constraints.
Scope Boundaries#
Deferred for later
- Caliber normalization / a caliber taxonomy —
caliberstays free text. - Filtering by
actionorsubtype— v1 filters bytypeonly; anactionfilter is a cheap follow-up on the same mechanism. - Magazine-compatibility-by-platform and other downstream uses — the taxonomy is a foundation for them but none are built here.
Outside this feature's identity
- Additional controlled classification axes (bore, loading mechanism, use) and NFA-style categories (SBR, AOW, suppressor) — not modeled as controlled fields;
subtype/notescover them ad hoc. - User-defined / UI-editable category lists — lists evolve in code via migration, not through the UI.
Outstanding Questions#
Deferred to Planning
- Whether to introduce a reusable select component or use a styled native
<select>(the form currently usesInput/datalistonly). - Exact placement and interaction of the
typefilter control on the list (no filtering UI exists yet). - Whether
subtypesuggestions are sourced from existing values per owner (likecaliber/manufacturer) or a static seed list.
Sources / Research#
src/db/inventory-schema.ts(firearmtable) — current shape: free-textcaliber/manufacturer, no classification columns.src/db/inventory-schema.ts(magazinecapacity checks;grantparent_type/permissionchecks) — the check-constraint backstop pattern (R26) to mirror fortype/action.src/db/inventory-schema.tsheader note — empty-not-null rule (R18) governingsubtype.src/domain/firearms/validate.ts— pure validator returning all codes (R20); extend with taxonomy codes.app/(app)/firearms/firearm-form.tsx— form using thedatalistsuggestion pattern forcaliber/manufacturer; reuse forsubtype, addtype/actionselectors.app/(app)/firearms/firearms-view.tsx— list table; no filtering exists yet, so thetypefilter is net-new UI.- Value sets (
type,action) and the required-on-write +unspecifiedbackfill behavior were confirmed with the owner during brainstorm dialogue. - Codebase patterns confirmed during planning:
src/domain/magazines/constants.ts—as constvalue/regex constants shared by validator, messages, and form. Pattern to mirror for the taxonomy value sets.src/db/inventory-schema.ts(grant_parent_type_valid,grant_permission_valid) —check("name", sql\${t.col} in ('a','b')`)idiom for thetype/action` backstops (R4/R26).src/domain/reference/reference.ts(distinctCalibers,calibersForInput,calibersForFilter) — per-owner distinct-value pattern;distinctSubtypesmirrors it for subtype suggestions (R9), and the filter-path split (*ForFilter) informs the type filter source (R11).src/domain/firearms/validate.ts/service.ts— pure validator returning all codes (R20), service persists raw values (R18/R19) and calls the validator before any write (R21).app/(app)/firearms/firearm-form.tsx/firearms-view.tsx/page.tsx—Input/datalistform (no<select>component exists),DataTablelist, server-side data fetch wiring.
Planning Contract#
Product Contract preservation: Product Contract unchanged. Planning enriches this artifact in place (HOW), preserving the Goal Capsule, Summary, Problem Frame, Key Decisions, R1–R15, and AE1–AE5 above.
Key Technical Decisions#
- KTD-A — Value sets live in one
as constmodule.src/domain/firearms/constants.tsexportsFIREARM_TYPESandFIREARM_ACTIONS(readonly tuples including theunspecifiedsentinel), their derived string-literal union types, and a helper that decides whether a value is a real (non-unspecified, in-set) selection. This single module feeds the domain validator, the DB check constraint expression, and the form option lists — no value set is duplicated. Mirrorssrc/domain/magazines/constants.ts. Rationale: satisfies R1/R2 and prevents the three enforcement surfaces (domain, DB, UI) from drifting. - KTD-B — Backfill rides the
ADD COLUMNdefault; no separate backfill script. The migration addstype/actionastext NOT NULL DEFAULT 'unspecified'andsubtypeastext NOT NULL DEFAULT ''. Postgres populates existing rows with the column default atADD COLUMNtime, so R12 is satisfied by the column definition itself. Check constraints (R4) are added in the same migration. Generated withbun run db:generate, applied withbun run db:migrate. Rationale: KISS — the default is the backfill, and every existing row lands on the valid stored sentinelunspecified. - KTD-C — Four validation codes; all-codes-together.
validateFirearmgainsinvalidType/invalidAction(value outside the allowed set, R6 — reachable only via a crafted request) andtypeRequired/actionRequired(value is theunspecifiedsentinel, R7). An empty or typo'd value falls intoinvalid*. Codes accumulate with the existingemptyName/emptyCaliberand return together (R20). Rationale: keeps R6 (out-of-set) and R7 (must-choose) as distinct, separately-messaged failures. - KTD-D — Native
<select>fortype/action;<datalist>forsubtype. No reusable select component exists (the form usesInput/datalistonly). Use styled native<select>elements labeled via the existingField/controlIdpattern (ARIA by accessible name, nodata-testid, R9);subtypereuses the caliberdatalistUX. Resolves Outstanding Question 1. Rationale: YAGNI — a shared select abstraction is not yet earned. - KTD-E — Type filter is client-side over the already-loaded list.
listFirearmsalready returns the full owner-scoped visible set; the filter is a client-side control infirearms-view.tsxover that in-memory array. Owner-scoping (AE4/R13) is therefore inherent — no query change, no cross-owner surface. Resolves Outstanding Question 2. Rationale: KISS; the list is small and already fully materialized. - KTD-F — Subtype suggestions are per-owner distinct in-use values. Add
distinctSubtypes(db, userId)toreference.ts, mirroringdistinctCalibers; there is no curated seed list for subtype, so the suggestion source is only what the owner has already typed. Resolves Outstanding Question 3. Rationale: consistency with the caliber/manufacturer suggestion model without inventing a static taxonomy the brainstorm explicitly rejected.
High-Level Technical Design#
The value set is defined once and fans out to three enforcement/consumption surfaces; the request flows through the same validator on the server that the form runs on the client.
Directional guidance for reviewers — the prose above and the unit definitions below are authoritative where they disagree.
Implementation Units#
Units are dependency-ordered. Data-model foundation (U1–U2) precedes enforcement (U3–U4), which precedes UI (U5–U6), which precedes end-to-end proof (U7).
U1. Taxonomy value-set constants#
- Goal: Establish the single source of truth for the
typeandactionvalue sets and the "is this a real selection" predicate. - Requirements: R1, R2 (advances R4, R6, R7, R9).
- Dependencies: none.
- Files:
src/domain/firearms/constants.ts(new);src/domain/firearms/__tests__/constants.test.ts(new). - Approach: Export
FIREARM_TYPES = ['pistol','revolver','rifle','shotgun','pcc','other','unspecified'] as constandFIREARM_ACTIONS = ['semi-auto','bolt','lever','pump','break','single-shot','unspecified'] as const, plus derived union types (FirearmType,FirearmAction) andSet-backed membership helpers. AddUNSPECIFIED = 'unspecified'and predicatesisRealFirearmType/isRealFirearmAction(in-set AND notunspecified). Include a display-label map covering every value in both sets, including the sentinel:semi-auto→ "Semi-automatic",pcc→ "PCC", andunspecified→ "Unspecified" (so the list column and filter render a friendly label for backfilled rows, not the raw slug). - Patterns to follow:
src/domain/magazines/constants.ts(as-const + derived helpers). - Test scenarios:
- Membership helper returns true for each in-set value and false for an unknown slug.
isRealFirearmType('unspecified')is false;isRealFirearmType('pistol')is true;isRealFirearmType('')is false.- Same three cases for
isRealFirearmAction. - The display-label map returns a presentation label for every
typeandactionvalue, includingunspecified→ "Unspecified".
- Verification:
bun test src/domain/firearms/__tests__/constants.test.tsgreen;bun run typecheckclean.
U2. Schema columns, check constraints, and migration#
- Goal: Add
type,action,subtypecolumns with the DB backstop and backfill existing rows. - Requirements: R1, R2, R3, R4, R5 (unchanged), R12.
- Dependencies: U1 (value sets referenced by the check expressions).
- Files:
src/db/inventory-schema.ts(modifyfirearmtable);src/db/migrations/(generated SQL +metasnapshot viabun run db:generate). - Approach: Add to the
firearmtable:type: text("type").notNull().default("unspecified"),action: text("action").notNull().default("unspecified"),subtype: text("subtype").notNull().default(""). Add two check constraints in the table's callback, building thein (...)list fromFIREARM_TYPES/FIREARM_ACTIONS(name themfirearm_type_valid,firearm_action_valid), mirroringgrant_parent_type_valid. Runbun run db:generateto emit the migration; confirm the generatedADD COLUMN ... DEFAULT ... NOT NULLbackfills existing rows and theADD CONSTRAINT ... CHECKclauses are present. Do not hand-edit the generated SQL beyond what drizzle-kit produces. - Execution note: After generating, inspect the migration SQL to confirm the defaults and both check constraints landed before moving on. The backfill outcome (R12 — legacy rows read back
unspecified/''and satisfy the new constraints) is exercised by U4's integration test, which inserts a row using the column defaults as a proxy for a pre-column row (the Testcontainers harness always migrates a fresh DB, so it cannot replay an ADD COLUMN against pre-existing rows); the migration is applied end-to-end bybun run db:migratein the Verification Contract. - Patterns to follow:
magazine/grantcheck-constraint blocks insrc/db/inventory-schema.ts; header comment's R18 empty-not-null rule forsubtype. - Test scenarios:
Test expectation: none— schema/migration scaffolding; behavior is proven by U4's integration tests (backfill validity, constraint rejection). - Verification:
bun run db:generateproduces exactly one new migration touching only thefirearmtable;bun run db:migrateapplies cleanly against a Testcontainers Postgres;bun run typecheckclean.
U3. Domain validation and user-facing messages#
- Goal: Reject out-of-set and
unspecifiedtype/actionon every write, with clear per-field messages. - Requirements: R6, R7, R8 (R20 convention).
- Dependencies: U1.
- Files:
src/domain/firearms/validate.ts(modify);src/domain/validation-messages.ts(modify);src/domain/firearms/__tests__/validate.test.ts(modify). - Approach: Extend
FirearmValidationCodewithinvalidType | invalidAction | typeRequired | actionRequired. Addtype: stringandaction: stringtoFirearmInput. InvalidateFirearm, after the existing checks: iftypenot inFIREARM_TYPES→invalidType, else iftype === UNSPECIFIED→typeRequired; same foraction. Preserve all-codes-together accumulation (R20). Add message strings:invalidType/invalidAction→ "Select a valid firearm type/action",typeRequired/actionRequired→ "Choose a firearm type/action". - Patterns to follow: existing
validateFirearmaccumulation;src/domain/validation-messages.tsmap. - Test scenarios:
Covers AE2.Missing selection (type: 'unspecified',action: 'unspecified') with valid name/caliber →['typeRequired','actionRequired'], nothing else.Covers AE3.Out-of-set value (type: 'blaster') → includesinvalidType.- Valid real selections (
type: 'pistol',action: 'semi-auto') plus empty name/caliber → returns only['emptyName','emptyCaliber'](taxonomy adds no codes). - All-fields-invalid input returns every applicable code together, not first-only (R20).
messageForCodereturns a non-default string for each of the four new codes.
- Existing-test migration:
FirearmInputnow requirestype/action, so the pre-existingvalidate.test.tscall sites (e.g.validateFirearm({ name, caliber })) must add realtype/actionto keep typechecking and to isolate the code they assert; their expected code lists are otherwise unaffected. - Verification:
bun test src/domain/firearms/__tests__/validate.test.tsgreen.
U4. Service persistence and input types#
- Goal: Carry
type/action/subtypethrough create/update, persisting raw values after validation. - Requirements: R3, R6, R7, R12, R13 (unchanged owner-scoping).
- Dependencies: U2, U3.
- Files:
src/domain/firearms/service.ts(modify);src/domain/firearms/__tests__/service.test.ts(modify). - Approach: Add
type: string,action: string,subtype?: stringtoFirearmCreateInput(subtype optional → empty-not-null). ExtendpersistableFieldsto includetype: input.type,action: input.action,subtype: input.subtype ?? "". Validation already runs at the top ofcreateFirearm/updateFirearm, so the new codes gate writes automatically — confirm the validator receivestype/action.FirearmUpdateInputkeeps omittingownerIdonly. - Execution note: Integration test-first for the required-on-edit path — it is the subtle behavior (editing a backfilled row must be rejected until reclassified).
- Patterns to follow: existing
persistableFields,createFirearm,updateFirearm; Testcontainerslivegate +factories.ts. - Test scenarios (integration,
DATABASE_URL-gated / Testcontainers):Covers AE2.createFirearmwithtype/action = 'unspecified'throwsValidationErrorand writes no row.- Create with real
type/actionpersists the exact slugs andsubtypeverbatim (raw-value R18/R19); omittedsubtypepersists as''. Covers AE3.Create with an out-of-settypethrowsValidationError(domain) — and a direct DB insert of an out-of-set value is rejected by the check constraint (backstop R4).Covers AE5.A row inserted with the column defaults (simulating a pre-feature/backfilled row) reads backtype='unspecified',action='unspecified',subtype=''and satisfies the constraints.Covers AE1.updateFirearmon a backfilled row still carryingunspecifiedthrowsValidationError(required-on-edit) unless a real value is supplied.- Owner-scoping unchanged: a non-owner cannot update another owner's firearm (existing authorize path still holds).
- Existing-test migration: pre-existing
service.test.tscreateFirearm/updateFirearmcall sites must pass realtype/action.src/test-support/factories.tsmakeFirearminserts via$inferInsert, which keepstype/action/subtypeoptional because they have DB defaults, so factory-based callers (auth/visibility/grants/magazines/csv/reference/summary tests) are unaffected. - Verification:
bun test src/domain/firearms/__tests__/service.test.tsgreen under Testcontainers.
U5. Form UI — type/action selects and subtype input#
- Goal: Let owners choose
type/actionand enter asubtypeon create and edit. - Requirements: R9 (advances R6, R7).
- Dependencies: U1, U3, U4. (Shares edits to
app/(app)/firearms/page.tsxwith U6; land U5 before U6 so the extendedFirearmFormValuesshape that U6'sFirearmListIteminherits already exists.) - Files:
app/(app)/firearms/firearm-form.tsx(modify);app/(app)/firearms/page.tsx(modify — passsubtypeSuggestions);src/domain/reference/reference.ts(adddistinctSubtypes);src/domain/reference/__tests__/*(add subtype coverage if a reference test file exists, else co-locate). - Approach: Extend
FirearmFormValueswithtype,action,subtype; defaultEMPTYtotype: 'unspecified',action: 'unspecified',subtype: ''. Render twoField-wrapped native<select>controls populated fromFIREARM_TYPES/FIREARM_ACTIONS(label each option via the display-label map; theunspecifiedoption renders as a placeholder-style "Select…" entry). Add asubtypeInputwithlist="firearm-subtypes"and a<datalist>fed by a newsubtypeSuggestionsprop. Wire client-side validation to surface the new codes viafirstMessage. Generalize the current two-way focus branch (found.includes("emptyName") ? nameId : calId) into a code→field-id lookup that focuses the first failing field in document order (name → caliber → type → action → subtype), so the new required selects are reachable on submit-fail (R9 accessibility) — six codes can now co-occur. Inpage.tsx, fetchsubtypeSuggestionswith a newsubtypesForInput/distinctSubtypescall in the existingPromise.all. AdddistinctSubtypes(db, userId)toreference.tsmirroringdistinctCalibers(owner-visible distinct non-blankfirearm.subtype). - Patterns to follow: existing
Field/Input/datalistusage infirearm-form.tsx;distinctCalibersinreference.ts;calibersForInputwiring inpage.tsx. - Test scenarios:
distinctSubtypesreturns owner-visible distinct non-blank subtypes, sorted ascending, excluding other owners' values (integration, Testcontainers).distinctSubtypesreturns[]when the owner has no subtypes.- Form-level behaviors (select renders all options, missing selection blocks submit) are proven in U7 e2e rather than markup assertions.
- Verification:
bun run typecheckclean;bun testgreen for the reference helper; form behavior covered by U7.
U6. List Type column and client-side type filter#
- Goal: Show each firearm's
typein the list and let owners filter the list bytype. - Requirements: R10, R11 (AE4, R13 owner-scoping inherent).
- Dependencies: U4 (rows now carry
type), U5 (FirearmListItemextends U5's extendedFirearmFormValues; both units editpage.tsx, so U6 lands after U5). - Files:
app/(app)/firearms/firearms-view.tsx(modify);app/(app)/firearms/page.tsx(modify — maptype/action/subtypeintoFirearmListItem). - Approach:
FirearmListItemextendsFirearmFormValues, so U5's extension already carriestype/action/subtypeonto the type; map the new fields into the items inpage.tsx. Add aTypecolumn to theDataTablerendering the U1 display label (unspecifiedshows as "Unspecified" so backfilled rows read clearly); render anActioncolumn too —DataTablealready wraps inoverflow-x-auto, so it is always shown rather than gated on a viewport heuristic (R10). Add a labeled filter control (native<select>) above the table with an "All types" option plus the types present in the current list (includingunspecifiedwhen present, so unclassified rows stay filterable rather than hidden); filter the rendered rows client-side by matchingtype. When a filter selection matches zero rows, render an inline "No firearms match this filter." message in the table body — distinct from the cold-startEmptyState, which stays reserved for the owner having zero firearms (avoids wrongly showing the "Add your first firearm" CTA to an owner who has firearms of other types). Because the list is already the owner's visible set, no query changes and no cross-owner exposure (AE4/R13). - Patterns to follow: existing
DataTable/TH/TDusage and theshowSerialconditional-column pattern infirearms-view.tsx; label map from U1. - Test scenarios:
Covers AE4.Filter behavior (select a type → only matching rows shown; "All" restores) proven in U7 e2e.- Zero-match filter shows the inline "no match" message, not the cold-start empty state — proven in U7 e2e.
- Display-label mapping is unit-covered by U1's display-label test; column rendering is covered by U7.
- Verification:
bun run typecheckclean; visual/behavioral coverage in U7.
U7. End-to-end coverage#
- Goal: Prove the taxonomy selectors on the form and the list type-filter end to end.
- Requirements: R14 (via U3/U4 integration), R15.
- Dependencies: U5, U6.
- Files:
e2e/firearm-taxonomy.spec.ts(new);e2e/inventory-crud.spec.ts(modify — its firearm-create step fills only Name/Caliber and asserts the "Firearm logged" toast; with type/action now required it must select a real Type and Action before submitting, or the client blocks the submit and the spec fails). - Approach: Add a Playwright spec following the existing harness (
start-test-server.ts, Testcontainers-backed, ARIA/accessible-name targeting — nodata-testid). Sign in via the existing fixture, exercise create-with-classification, edit-forces-classification, and list filtering. - Patterns to follow:
e2e/inventory-crud.spec.ts,e2e/magpul-mode.spec.ts;e2e/README.mdharness notes. - Test scenarios:
Covers AE2.Open the new-firearm form, fill name + caliber, leave type/action at the placeholder, submit → per-field validation messages appear and no row is created.- Create a firearm with
type = Pistol,action = Semi-automatic, a subtype value → it appears in the list with the Type shown. Covers AE4.With firearms of mixed types, selectPistolin the type filter → only pistols remain visible; "All types" restores the full list.Covers AE1.Edit an existing firearm whose type is unset (placeholder) and attempt to save without choosing → save is blocked with a prompt to choose a type.- Filtering to a type with no matching firearms shows the inline "No firearms match this filter." message and does not show the "Add your first firearm" cold-start CTA.
- Verification:
bun run test:e2epasses forfirearm-taxonomy.spec.ts, and the updatedinventory-crud.spec.tsstill passes (Docker required).
Verification Contract#
Gates that must pass before the work is considered complete:
bun run lint(Biome) clean on changed files.bun run typecheckclean.bun test srcgreen, including the new/extendedconstants,validate,service, andreferencetests (integration tests run against Testcontainers Postgres via theDATABASE_URLgate).bun run db:migrateapplies the generated migration cleanly and existing rows read back theunspecified/''defaults (AE5).bun run test:e2epasses fore2e/firearm-taxonomy.spec.ts.- Every acceptance example AE1–AE5 is exercised by at least one named test scenario above.
Definition of Done#
firearmrows carry controlledtype/actionand free-textsubtype; existing rows backfilled tounspecified/unspecified/''(R1–R3, R12).- Domain validation rejects out-of-set and
unspecifiedtype/actionon create and edit, returning all codes together; DB check constraints backstop it (R4, R6–R8). - The firearm form exposes type/action selects and a subtype suggestion input; the list shows Type and can be filtered by type, owner-scoped (R9–R11, R13).
- All Verification Contract gates pass; AE1–AE5 covered.
- Product Contract unchanged; scope boundaries (deferred: caliber taxonomy, action/subtype filtering, platform features; outside: extra controlled axes, user-defined categories) respected.