Accessories Tracker - Plan#
Goal Capsule#
- Objective: Let an owner track aftermarket parts (triggers, barrels, optics, suppressors, grips, stocks, etc.), which firearm each is mounted on, what it cost, and whether it is an NFA-regulated item — so the collection record supports valuation, insurance, and (later) service scheduling.
- Product authority: The individual firearm owner (STRATEGY primary persona) drives scope; accessories serve the "relational domain depth" and "owned data becomes owned insight" tracks.
- Open blockers: None. Planning resolved the four deferred questions — money storage (integer cents, KTD2), primary-nav placement (app-shell, U5), category seed list (constants, KTD3), owner-wide valuation (deferred to follow-up, KTD6).
Product Contract#
Summary#
Add an Accessory as a fourth owner-scoped inventory item alongside Firearm, Magazine, and Ammo. An accessory mounts to one firearm at a time and can be moved between firearms while keeping its identity, cost, serial, and NFA status. A mounted accessory is visible to whoever its firearm is shared with; unmounted accessories are private to the owner. Accessories surface on their own top-level screen and as a mounted-parts section on the firearm detail page, with a per-firearm accessory-value total.
Problem Frame#
Owners customize firearms heavily, and the parts carry real money and, for suppressors and other NFA items, real legal weight. Today none of that is recorded: there is no accessory entity, no place to note what an optic cost or which suppressor is registered to what, and no way to keep that true as parts move between guns. The parts also don't stay put — an optic or a suppressor is routinely swapped across hosts, so a model that nails a part to one firearm drifts out of sync the moment the owner moves it. This is exactly the relational fact a spreadsheet can't hold: one part, changing hosts, retaining its own value and serial.
Key Decisions#
- Accessory is an owner-scoped entity, but not independently shareable. It is owned directly by the user (like Ammo) so it can exist unmounted "in the safe" — which rules out a firearm child record, since a child would be removed with its firearm and couldn't stand alone. But it carries no grants of its own: a mounted accessory inherits the visibility and permission of the firearm it is on, and an unmounted accessory is private to its owner. Independent per-accessory sharing was considered and deferred — no goal requires sharing a bare accessory apart from its firearm, and dropping it collapses a grant target, the visibility rules, and the permission model.
- Mounting is a current-assignment link, with one bounded exception. An accessory points at the firearm it is currently on (or nothing, when it is unmounted "in the safe"). There is no general timeline of past mounts — the sole exception is that a Range Session records which accessories were mounted for it, so an accessory's rounds fired (e.g., through a suppressor or barrel) can be derived for wear and service tracking, and so the planned range-performance logging can compare how the operator shot across accessory configurations on the same firearm.
- Category is free-text with suggestions, not an enforced taxonomy. Accessory kinds keep growing (lights, lasers, slings, magwells, charging handles), so the Ammo load-type pattern fits better than the firearm Type/Action pattern with its CHECK constraint and a migration per new kind.
- Cost carries a per-firearm valuation rollup. The insurance/valuation motivation wants a total, not just a field, so a firearm's detail shows the summed value of the accessories mounted on it.
- NFA is a simple flag in v1, on both the Firearm and the Accessory. A yes/no marker for regulated items — suppressors and other NFA accessories, and SBR/AOW/machine-gun firearms. Serials on both are treated as sensitive, exactly like existing firearm serials.
Requirements#
Entity and fields
- R1. An Accessory is an owned inventory item carrying a category, brand, model, optional serial number, optional installed date, optional cost, notes, and an NFA flag.
- R2. Category is free text with suggested values (trigger, barrel, sight, optic, suppressor, grip, stock, muzzle device, light, laser, sling, magwell, other), not a fixed enforced set.
- R3. Category is required on save; brand, model, serial, installed date, cost, and notes are all optional.
Mounting and lifecycle
- R4. An Accessory may be mounted to at most one of the owner's firearms at a time, or left unmounted. When the owner has no firearms yet, the mount control is omitted (or shown disabled) and the Accessory saves as unmounted.
- R5. Moving an Accessory between firearms is a reassignment that preserves its identity, cost, serial, and NFA status — never a delete-and-recreate.
- R6. Installed date records when the current mount began and is optional; reassigning an Accessory to a different firearm resets it to reflect the new mount.
- R19. A Range Session records which accessories were mounted on its firearm at the time of the session. This supports deriving an accessory's rounds fired (summed across the sessions it was attached for) and, for the planned range-performance logging, correlating performance with the accessory configuration (e.g., how the operator shot with one optic vs. another on the same firearm). Later reassigning or removing the accessory does not alter past sessions. This is the only mount history v1 keeps.
Sharing and visibility
- R7. An Accessory is owner-scoped and is not shared through its own grant. A mounted Accessory inherits the visibility and permission of the firearm it is on; an unmounted Accessory is visible only to its owner.
- R8. A mounted Accessory is visible to exactly the viewers who can see its firearm and appears in that firearm's mounted-accessories section for all of them. Moving or unmounting the Accessory changes who can see it accordingly.
- R9. Editing or deleting a mounted Accessory requires owner or edit permission on its firearm; an unmounted Accessory can be changed only by its owner. View grantees see accessories read-only.
- R17. Mounting, reassigning, or unmounting an Accessory requires owner or edit permission on every firearm involved (the current one and the target); the firearm picker offers only firearms the actor can edit. A user cannot mount an Accessory onto a firearm they cannot edit.
Cost and valuation
- R10. Each Accessory carries an optional cost.
- R11. A firearm's detail shows a derived total of the cost of the accessories currently mounted on it — computed on read, never stored. Because mounted accessories are visible to everyone who can see the firearm, the total is the same for every viewer.
Serials and NFA
- R12. An Accessory carries an NFA flag marking regulated items (suppressors especially, which carry their own serial numbers).
- R13. Accessory serial numbers are sensitive and are never written to CSV export, matching the existing "serials never exported" rule for firearm serials.
- R18. The Firearm entity also carries an NFA flag, marking regulated firearms (SBR, AOW, machine gun). It mirrors the Accessory NFA flag; existing firearm serial sensitivity is unchanged.
Surfaces
- R14. Accessories have a top-level surface — a list plus a per-accessory detail route — mirroring Firearms, Magazines, and Ammo, so unmounted accessories stay reachable and manageable.
- R15. The firearm detail page gains a mounted-accessories section listing the accessories currently on that firearm.
- R16. Create, edit, and delete reuse the existing confirm-dialog and delete-confirmation patterns for destructive actions.
Key Flows#
-
F1. Add an accessory
- Trigger: Owner adds a part, from either the Accessories screen or a firearm's detail.
- Steps: Owner enters category (with suggestions) and optional fields, optionally selects a firearm to mount it on, and saves. When add is launched from a firearm's detail page, the mount target pre-fills to that firearm (still changeable or clearable before save).
- Outcome: Accessory exists, owned by the user, mounted or unmounted.
- Covers: R1, R2, R3, R4, R14, R15
-
F2. Move or unmount an accessory
- Trigger: Owner (or an edit grantee) reassigns a part to a different firearm, or removes it to the safe.
- Steps: Owner changes the accessory's mounted firearm to another of the owner's firearms, or clears it.
- Outcome: The accessory keeps its cost, serial, and NFA status; the old firearm's valuation drops and the new one's rises.
- Covers: R4, R5, R11
-
F3. Share a firearm's accessories (inherited)
- Trigger: Owner shares a firearm through the existing firearm share control.
- Steps: No accessory-specific action — the firearm's grant governs the accessories mounted on it.
- Outcome: The grantee sees (or, with an edit grant, can change) the accessories mounted on that firearm; unmounted accessories stay private to the owner.
- Covers: R7, R8, R9
Acceptance Examples#
- AE1. Covers R8. A firearm is shared view-only to Bob, with an optic mounted on it. Then Bob sees the optic (read-only) in the firearm's mounted-accessories list and its cost in the total, because accessory visibility follows the firearm. An accessory the owner leaves unmounted is not visible to Bob.
- AE2. Covers R11. A firearm has two visible mounted accessories at $400 and $150 and one with no cost. Then the viewer sees an accessory-value total of $550; the costless accessory contributes zero; unmounted accessories are not counted.
- AE3. Covers R5, R11. An optic mounted on firearm A is moved to firearm B. Then the optic retains its cost and serial, firearm A's accessory-value total drops by the optic's cost, and firearm B's rises by it.
- AE4. Covers R13. The owner exports inventory to CSV. Then no accessory serial number appears in the file.
- AE5. Covers R12. The owner marks an Accessory (e.g., a suppressor) as an NFA item on save. Then the NFA marker persists and displays wherever the Accessory's details render, to every viewer who can see the Accessory.
Scope Boundaries#
Deferred for later:
- A general install/remove history timeline (which part was on which firearm, when). v1 keeps only the current mount plus the per-range-session accessory snapshot (R19).
- Independent per-accessory sharing (a fourth grant target). Deferred until a concrete need to share a bare accessory apart from its firearm appears.
- Range-performance logging itself — a separate planned feature. R19 only captures the accessory-to-session linkage it will build on.
- Photos, documents, service intervals, and shot count on firearms or accessories — issue 8 gestures at these, but each is its own feature.
- Accessories as a target of the (not-yet-built) Service Intervals feature.
- Richer NFA data (tax-stamp / Form 4 tracking) beyond the yes/no flag.
- Adding firearms or accessories to CSV export — neither is exported today, so R13 is satisfied by construction rather than by new redaction code.
Outstanding Questions#
Resolved during planning (see Planning Contract):
- Money storage → integer minor units,
cost_cents(KTD2). - Primary-nav placement → an app-shell nav entry alongside Firearms/Magazines/Ammo (U5).
- Category seed list → R2's values, in
src/domain/accessories/constants.ts(KTD3). - Owner-wide valuation rollup → deferred to follow-up; v1 ships per-firearm only (KTD6).
Sources / Research#
- Owner-scoping, the child-record seam, and the Grant model are codified in
CONCEPTS.md(Relationships, Child record, Grant). Accessory mirrors Ammo's owner-scoped shape but carries no grants of its own — its visibility follows the firearm it is mounted on, so the Grant model is not extended. - Ammo table shape to mirror for an owner-scoped entity:
src/db/inventory-schema.ts(ammo, lines ~141–167); the child-record FK+cascade shape israngeSession(~216–238); the Grant model withparentTypeisgrant(~240+). - The firearm detail view already exists and is where the mounted-accessories section and valuation total land:
app/(app)/firearms/[id]/page.tsxandapp/(app)/firearms/firearm-detail-view.tsx(which already composesShareControl,RangeSessionHistory,InventoryLogHistory,ConfirmDialog, anduseDeleteConfirmation). - Serial redaction rule: CSV export is magazines-only and "serial is never a column" in
src/domain/csv/serialize.ts(R45). Firearms and accessories are absent from CSV entirely —app/api/export/route.ts. - Free-text-with-suggestions precedent (Ammo load type) vs. enforced taxonomy (Firearm Type/Action):
src/domain/ammo/constants.tsandsrc/domain/firearms/constants.ts.
Planning Contract#
Product Contract preservation: unchanged by this enrichment. R17/R18/R19 and the inherit-from-mount sharing model were settled during the ce-doc-review pass that preceded planning; planning builds on them without altering product scope.
Key Technical Decisions#
- KTD1. Accessory is owner-scoped but not a grant
ParentType. The grantparent_typeset stays('firearm','magazine','ammo'). Anaccessoryrow carriesowner_idplus a nullablecurrent_firearm_id. A mounted accessory's visibility is derived from its firearm (owned ∪ firearm-granted); an unmounted accessory is owner-only. This is the one seam that does not mirror Ammo — Ammo is a grant target, Accessory is not — because R7/R8 require visibility to follow the firearm and the fourth grant target was dropped. - KTD2. Cost is stored as integer minor units (
cost_cents). Mirrors the repo's integer-column convention (grain,quantity_rounds; int4-bounded in #53) and avoids floating-point money; formatting to currency happens at the edge. - KTD3. Category is free text with a suggested list, no CHECK constraint. Mirrors
src/domain/ammo/constants.tsload-type suggestions, not the firearm Type/Action CHECK. Seed list = R2's values. - KTD4.
current_firearm_idusesonDelete: set null. Deleting a firearm unmounts its accessories (they survive, owner-scoped) rather than cascading them away — an accessory outlives the guns it rides on. Contrast Range Session, which cascades. - KTD5. Mount/reassign authorization checks the firearm side and keeps accessories on their owner's guns.
mountAccessoryrequires own-or-edit on the accessory, edit on the target firearm (authorizeUpdate(tx, actor, "firearm", firearmId)), AND that the target firearm is owned by the accessory's owner. The same-owner rule stops a shared-firearm edit-grantee from relocating someone else's accessory onto an unrelated third party's gun (which would leak the accessory to that gun's viewers). Closes the cross-tenant-mount gap (R17). - KTD6. Per-firearm valuation is a derived read; owner-wide rollup deferred. R11 sums
cost_centsover the firearm's mounted accessories at read time (no stored counter), mirroring the firearm Lifetime Total derivation. Owner-wide valuation ships in a follow-up. - KTD7. Range Session ↔ Accessory linkage snapshots the mount set at session-create. A
range_session_accessoryjoin records which accessories were mounted when the session was logged; per-accessory rounds fired = sum ofrounds_firedover its linked sessions. Later reassigning/removing the accessory does not alter past sessions (R19). This is the only history v1 keeps, and it seeds the planned range-performance logging.
High-Level Technical Design#
Data model (new/changed in bold):
Accessory permission resolution (KTD1 — not a grant lookup):
Sequencing#
Data layer first (U1), then domain (U2–U4), then UI (U5–U6), then the range-session linkage (U7), then end-to-end tests (U8). U3 (visibility/authz) gates U4 (service); U4 gates the UI units. U7 depends on U1 (join table) and U4 (mount state).
Assumptions#
- Migrations are generated with
drizzle-kit generate(drizzle.config.ts) and applied withbun run db:migrate; new files land as0011_*(accessory + firearm NFA) and0012_*(range-session join). - No CSV work: accessories are absent from CSV export (Scope Boundaries), so R13 holds by construction — no serializer changes (
src/domain/csv/*untouched).
Implementation Units#
U1. Accessory + firearm-NFA schema and migration#
- Goal: Add the
accessorytable, theis_nfafirearm column, and generate the migration. - Requirements: R1, R3, R4, R6, R10, R12, R18
- Dependencies: none
- Files:
src/db/inventory-schema.ts,src/db/schema.ts(re-exportaccessory),src/db/migrations/0011_*.sql(generated),src/db/__tests__/schema.test.ts - Approach: Add
accessorypgTable mirroringammo's owner-scoped shape (inventory-schema.ts ~141):iduuid pk,owner_idtext FK user cascade,current_firearm_iduuid FK firearmonDelete: set null(nullable, KTD4),categorytext notNull,brand/model/serial_number/notestext notNull default''(empty-not-null, R18 convention),installed_datedate nullable,cost_centsinteger nullable (KTD2),is_nfaboolean notNull default false, created/updated timestamps. Indexes onowner_idandcurrent_firearm_id. Addis_nfa boolean notNull default falsetofirearm. Do not touch the grantparent_typeCHECK. - Patterns to follow:
ammotable for the owner-scoped shape;rangeSession.firearmIdFK for the firearm reference (butset null, not cascade); int4 bounds per #53. - Test scenarios: schema test asserts accessory columns + defaults and firearm
is_nfadefault false; a fresh Testcontainers migrate applies cleanly.Test expectation: nonefor the generated SQL itself — behavior is exercised by U3/U4. - Verification:
bun run db:migrateapplies on a fresh Postgres;bun run typecheckgreen.
U2. Accessory validation + constants#
- Goal: Field validation and category suggestions.
- Requirements: R2, R3
- Dependencies: U1
- Files:
src/domain/accessories/validate.ts,src/domain/accessories/constants.ts,src/domain/accessories/__tests__/validate.test.ts - Approach:
validateAccessoryreturns error codes: category required/non-empty (R3);cost_cents >= 0when present;installed_datea validYYYY-MM-DDwhen present.constants.tsexports the category suggestion list (R2's values, incl.suppressor). Mirrorsrc/domain/ammo/validate.ts+constants.ts. - Test scenarios: empty category → error; negative cost → error; category-only minimal input → ok; suggestions list contains
suppressorandoptic. - Verification:
bun testunit suite green.
U3. Accessory visibility & mount authorization#
- Goal: The inherit-from-mount visibility/permission seam and mount authz (the one non-Ammo seam).
- Requirements: R7, R8, R9, R17
- Dependencies: U1
- Files:
src/auth/accessory-visibility.ts,src/auth/__tests__/accessory-visibility.test.ts - Approach:
listVisibleAccessoryIds(db, userId)= accessories owned by user ∪ accessories whosecurrent_firearm_id ∈ getVisibleIds(db, userId, "firearm").resolveAccessoryPermission(db, userId, accessoryId):ownerif owned; else if mounted on a firearm the user can see, return that firearm's permission viaresolvePermission(db, userId, "firearm", current_firearm_id); elsenull.authorizeMount(tx, actorId, accessoryId, targetFirearmId | null): require own-or-edit on the accessory AND (target null OR (authorizeUpdate(tx, actorId, "firearm", targetFirearmId)AND the target firearm'sowner_idequals the accessory'sowner_id)). Reusesrc/auth/visibility.tshelpers; do not extendParentType. - Patterns to follow:
getVisibleIds/resolvePermission(src/auth/visibility.ts);authorizeUpdate(src/auth/authorize.ts). - Test scenarios: owner sees own mounted + unmounted; firearm view-grantee sees the mounted accessory read-only (Covers AE1) but not an unmounted one; firearm edit-grantee gets edit on its mounted accessories; mount onto a non-editable firearm is rejected (Covers R17); mount onto a firearm owned by a different user is rejected even when the actor can edit both firearms (Covers R17 cross-owner); unmount by a non-owner / non-firearm-editor is rejected. Integration tests gate on
DATABASE_URL(Testcontainers). - Verification:
bun testgreen with a live DB.
U4. Accessory service (CRUD + mount/reassign/unmount)#
- Goal: Visibility-scoped CRUD plus mount operations.
- Requirements: R1, R4, R5, R6, R9, R17
- Dependencies: U2, U3
- Files:
src/domain/accessories/service.ts,src/domain/accessories/__tests__/service.test.ts - Approach:
create/update/get/listmirrorsrc/domain/ammo/service.ts, but scope through U3's helpers (listVisibleAccessoryIds,resolveAccessoryPermission) instead ofgetVisibleIds("ammo").mountAccessory(actor, accessoryId, firearmId | null)callsauthorizeMount, setscurrent_firearm_id, and resetsinstalled_dateon reassignment (R6). Delete of a mounted accessory follows the same permission as edit — owner or edit on its current firearm (viaresolveAccessoryPermission); an unmounted accessory is owner-only delete (R9). A bespoke delete path is needed: accessories are not aParentType, so there is noauthorizeAndDeleteParentreuse, and they have no grants to clean up. - Test scenarios: create mounted and unmounted; move between firearms preserves cost/serial/NFA and resets installed_date (Covers AE3); unmount;
listreturns the visible set;getoutside the visible set → not-found; edit permission follows the firearm; a firearm edit-grantee can delete a mounted accessory but not an unmounted one (Covers R9). - Verification: service tests pass with a live DB.
U5. Top-level Accessories surface (list, detail, form, actions, nav)#
- Goal: The standalone Accessories screens and nav entry, so unmounted accessories stay reachable.
- Requirements: R1, R2, R3, R4, R12, R14, R16
- Dependencies: U4
- Files:
app/(app)/accessories/page.tsx,app/(app)/accessories/accessories-view.tsx,app/(app)/accessories/accessory-form.tsx,app/(app)/accessories/accessory-detail-view.tsx,app/(app)/accessories/actions.ts,app/(app)/accessories/[id]/page.tsx,app/(app)/app-shell.tsx(nav entry) - Approach: Mirror
app/(app)/ammo/*(page → view → form → detail + server actions). The form carries category (suggestion datalist), brand, model, serial, installed date, a cost input mapped tocost_cents, notes, an NFA checkbox, and a firearm mount selector whose options are the actor's editable firearms — omitted/disabled with a "save unmounted" affordance when the actor has none (R4). Detail view reusesConfirmDialog+useDeleteConfirmationfor delete (R16) and edit-in-place likefirearm-detail-view.tsx. Add the nav entry inapp-shell.tsxalongside Firearms/Magazines/Ammo. - Patterns to follow:
app/(app)/ammo/*;app/(app)/firearms/firearm-detail-view.tsxfor edit-in-place + delete. - Test scenarios: behavior covered by U8 e2e.
Test expectation: noneat unit level for the server-component wiring — server actions delegate to U4, which is unit-tested; UI is verified via e2e. - Verification:
bun run typecheck,bun run lint; renders in dev.
U6. Firearm detail: mounted-accessories section, valuation total, firearm NFA#
- Goal: Surface accessories on the firearm detail page and add the firearm NFA flag.
- Requirements: R8, R11, R15, R18
- Dependencies: U4
- Files:
app/(app)/firearms/[id]/page.tsx,app/(app)/firearms/firearm-detail-view.tsx,app/(app)/firearms/firearm-form.tsx,app/(app)/firearms/mounted-accessories.tsx(new),src/domain/accessories/service.ts(addlistMountedForFirearm+ valuation helper) - Approach: The page fetches the accessories mounted on the firearm (all firearm-viewers see them, KTD1) and the derived cost total, and passes them to a new mounted-accessories section in the detail view (list + link to each accessory + an add-accessory entry pre-filled to this firearm, F1). Add an NFA checkbox to
firearm-form.tsxand display it in the detail view (R18). Valuation total is derived on read and currency-formatted (R11). - Patterns to follow: the existing
RangeSessionHistory/InventoryLogHistorysections infirearm-detail-view.tsx;magazineCountForFirearmfor the fetch-and-count shape. - Test scenarios: covered by U8 e2e — valuation sums mounted accessories, costless contributes 0, unmounted excluded (Covers AE2); firearm NFA persists and displays (Covers AE5-adjacent for firearms); mounted section shows accessories to a firearm grantee (Covers AE1).
- Verification:
bun run typecheck; renders in dev.
U7. Range Session ↔ Accessory linkage#
- Goal: Capture the mounted accessories per range session; derive per-accessory rounds fired.
- Requirements: R19
- Dependencies: U1, U4
- Files:
src/db/inventory-schema.ts(range_session_accessoryjoin) +src/db/migrations/0012_*.sql,src/db/schema.ts(re-export),src/domain/range-sessions/service.ts,src/domain/range-sessions/__tests__/service.test.ts - Approach: Add
range_session_accessorywith a surrogateiduuid PK, a unique index on (range_session_id,accessory_id),range_session_idFK cascade, andaccessory_idFKonDelete: set null— nullable, because a composite PK cannot hold the set-null side (PK columns are NOT NULL). On range-session create, snapshot the firearm's currently-mounted accessories into the join. Derive per-accessory rounds = sum ofrounds_firedover its linked sessions. A later accessory delete setsaccessory_idnull (the session row survives, R19). When rendering a session's linked accessories, gate each accessory's fields (serial, cost, NFA, brand/model) through the viewer's currentresolveAccessoryPermission— a since-unmounted (owner-only) accessory shows a placeholder to other viewers, never its details (R7). - Test scenarios: creating a session links the firearm's mounted accessories (Covers R19); per-accessory rounds sums its linked sessions; deleting an accessory leaves the session row (with
accessory_idnull); reassigning an accessory later does not change past linkage; a firearm view-grantee viewing an old session does not see the fields of an accessory that has since been unmounted (Covers R7). - Verification: service + schema tests pass with a live DB.
U8. End-to-end tests + factories#
- Goal: Playwright e2e coverage and a shared accessory factory.
- Requirements: R4, R5, R7, R8, R11, R12, R13, R14
- Dependencies: U5, U6, U7
- Files:
e2e/accessories.spec.ts,src/test-support/factories.ts(accessory factory) - Approach: e2e (Testcontainers/Docker,
bun run test:e2e): create an accessory (mounted and unmounted); move it between firearms; share a firearm and confirm the grantee sees its mounted accessory read-only but not an unmounted one (Covers AE1); read the per-firearm valuation total (Covers AE2); mark an accessory as NFA and confirm the marker displays to a firearm view-grantee (Covers AE5); export CSV and confirm no accessory serial appears (Covers AE4 — trivially, accessories are absent from CSV). Target UI via ARIA roles / accessible names / visible text — nodata-testid. - Execution note: the sharing-inheritance and cross-tenant-mount paths are the highest-risk behaviors; prove them with integration/e2e coverage, not mocks.
- Test scenarios: as above.
- Verification:
bun run test:e2egreen.
Verification Contract#
| Gate | Command | Applies to |
|---|---|---|
| Types | bun run typecheck | all units |
| Lint | bun run lint (biome) | all units |
| Unit + integration | bun test (integration gates on DATABASE_URL, Testcontainers) | U1–U4, U7 |
| Migrations | bun run db:migrate on a fresh Postgres | U1, U7 |
| E2E | bun run test:e2e (Docker) | U5, U6, U8 |
| Pre-commit gate | just ci-check — MUST pass before every commit | all |
Acceptance coverage: AE1 in U3/U8, AE2 in U6/U8, AE3 in U4, AE4 in U8, AE5 in U8.
Definition of Done#
- R1–R19 satisfied and each traced to a unit; AE1–AE5 covered by tests.
- New
accessorytable,firearm.is_nfacolumn, andrange_session_accessoryjoin migrated;bun run db:migrateclean on a fresh DB. - Accessories CRUD + mount/move/unmount work owner-scoped; mounted accessories inherit their firearm's visibility; unmounted accessories are owner-only; cross-tenant mount is rejected (R17).
- Per-firearm valuation total renders; firearm and accessory NFA flags persist and display.
- Range-session → accessory linkage captured; per-accessory rounds fired derivable.
just ci-checkgreen; unit, integration, and e2e suites green.- No
data-testidadded; UI targeted via ARIA roles / accessible names / visible text. - Abandoned or experimental code removed from the diff.
- Out of scope and untouched: owner-wide valuation, a general mount-history timeline, photos/documents/service-intervals/shot-count, independent per-accessory sharing, and CSV export of accessories.