Ammo Inventory Tracking with Low-Stock Alerts - Plan#
Goal Capsule#
- Objective: Track ammunition on hand as an owner-scoped, shareable inventory entity — recording stock by brand, caliber, load type, and grain — so an owner can see how much they have per caliber and be flagged before a lot runs low.
- Product authority: GitHub issue #7, plus the Product Contract below.
- Product Contract preservation: unchanged. Planning resolved two forks the brainstorm left open (ammo sharing is edit-capable, matching Actor A2; ammo CSV is a dedicated export, not merged into the magazine CSV) and one field-requiredness assumption (AS3) — none alter product scope.
- Execution profile: Deep feature mirroring the shipped owned-parent stack (Firearm/Magazine) end to end. Ammo becomes the third owned parent. Testcontainers-backed integration + Playwright e2e.
just ci-checkis the pre-commit gate. - Open blockers: None. All four issue-flagged product questions plus the two planning forks are resolved.
- Stop conditions: No consumption/"consume rounds" events, no range-session round deduction, no trip reservation/allocation, no lot merging, no per-load analytics. Surface a genuine blocker rather than expanding scope.
Product Contract#
Summary#
Add an owner-scoped, shareable ammo inventory entity that follows the exact ownership and grant model already used by firearms and magazines. Owners record ammo lots (brand, caliber, load type, grain, round count, low-stock threshold, acquired date, notes) on a dedicated /ammo route with the established inventory flows — list, create/edit form, empty state, toasts, and delete-with-ConfirmDialog. A lot is flagged low-stock when quantityRounds <= lowStockThreshold, both inline in the list and as roll-ups on /summary. Ammo is included in CSV export. The design closes the "what can I actually shoot right now" gap and leaves the seam open for future consumption features without a redesign.
Problem Frame#
The app models firearms, magazines, and caliber reference data, but not ammunition on hand. Owners can't see how much ammo they have for a given caliber, can't be warned about low inventory before a range trip, and the summary can't answer cross-cutting questions like "I own firearms in this caliber — do I have ammo for them?" or "which calibers are below my comfort threshold?" Adding ammo inventory closes that gap and lays a foundation for later consumption tracking, range logs, and automated stock deductions.
Key Decisions#
- Ammo is the third owned parent, mirroring the existing pattern. It reuses the owner-scoped
pgTableconventions, the hand-rolled code-array validator style (not zod), the polymorphicgrantsharing model, the sharedDataTableUI, and the parent-generic auth (getVisibleIds/resolvePermission). No new architecture — it extends seams the codebase already left open (notably the FK-lessrangeSession.ammoIdseam labeled "future Ammo inventory (#7)"). This makes ammo the first entity to move the domain from "two owned parents" to three; thegrant.parentTypecontrolled set and theParentTypeunion gain'ammo', and CONCEPTS.md is updated accordingly. - Caliber reuses reference data as suggestions, not a whitelist. In this codebase caliber is free text validated only for non-empty; the "reference list" is a UI suggestion source (curated list ∪ in-use values), and firearm/magazine caliber already works this way. Ammo caliber wires into the same suggestion union and
distinctCalibers— consistent behavior, no hard FK or enum. (Recorded as assumption AS1.) - Load
typeis free text with suggestions. A combobox seeded with common loads (FMJ, JHP, HP, Match, Soft Point, Subsonic) that accepts any value — the same shape as caliber. Load types proliferate (+P, frangible, tracer, wadcutter); a hard enum would reject real entries until a code change. - Records are separate lots, never auto-merged. Two records sharing brand/caliber/type/grain remain independent lots, each with its own acquired date, quantity, threshold, and notes. A lot is the natural unit for the future "consume rounds" seam. Aggregation happens only in derived summary/caliber views, not by collapsing rows.
- Low-stock is a derived per-lot flag; summary rolls up both ways. A lot is low when
quantityRounds <= lowStockThreshold. The summary shows both counts: entries below threshold (count of low lots) and calibers below threshold (distinct calibers among low lots). No caliber-level threshold field exists — both counts derive from the single per-lot flag. - CSV export gains an ammo row shape with alert columns. CSV export today is magazine-only, so ammo needs its own row set regardless. That row set includes all ammo fields plus
lowStockThresholdand a computed low-stock status column, so the alert data lives in the exported sheet. - Caliber-coverage cross-reference (light) is in scope. The summary surfaces calibers the owner has firearms in but no/low ammo for. Caliber is already the join key and summary already aggregates by caliber, so it is a low-cost, high-value add that directly serves the issue's stated user question. Not one of the issue's acceptance criteria — included by decision.
Actors#
- A1. Owner — the user who owns the ammo lot. Full CRUD on their own lots; sees all their lots plus lots shared to them.
- A2. Grantee (edit) — a user granted
editon a shared ammo lot. Can update it, and (with the create-on-behalf opt-in) create lots on the owner's behalf, mirroring firearms. (Planning fork resolved: ammo sharing is edit-capable like firearms, not view-only like magazines.) - A3. Grantee (view) — a user granted
viewon a shared ammo lot. Sees it but cannot change it.
Requirements#
Data & CRUD
- R1. A user can create, read, update, and delete ammo lots.
- R2. A lot records:
brand(optional),caliber,type(load type, optional),grain(integer),quantityRounds(integer),lowStockThreshold(integer),acquiredDate(optional), andnotes(optional).caliberis the only required text field (AS3). - R3. Lots are owner-scoped: a user sees only their own lots plus lots explicitly shared to them via a grant, and this scoping governs all reads and writes.
- R4. Lots are shareable with the same grant model as firearms — per-item
view/editgrants, re-grant updates in place, and the create-on-behalf opt-in. - R5. Caliber values draw from the existing caliber reference suggestions and remain consistent with how firearm/magazine caliber behaves (suggestion-backed free text, validated non-empty).
- R6. Load
typeis suggestion-backed free text (common loads offered, any value accepted). - R7. Records with matching brand/caliber/type/grain remain separate lots; the system never auto-merges them.
- R8. Domain-layer validation rejects invalid input (empty caliber, negative counts) with all failure reasons, following the established validator style; database-level CHECK constraints mirror the critical numeric bounds.
Low-stock & summary
- R9. A lot is low-stock when
quantityRounds <= lowStockThreshold. - R10. The ammo list shows a visible low-stock indicator on each low lot.
- R11.
/summaryshows both roll-ups: count of ammo entries below threshold and count of calibers below threshold (distinct calibers among low lots — a caliber counts if any of its lots is low). - R12.
/summarysurfaces a caliber-coverage signal: calibers the owner has firearms in but has no ammo lots, or only low lots (all lots low) for. Distinct from R11's any-lot rule — a caliber with one low lot and one adequate lot is counted in R11 but is not flagged here.
UI/UX
- R13. A dedicated
/ammoroute provides list view, create/edit form, empty state, success/error toast feedback, and delete viaConfirmDialog, built on the sharedDataTableand existing inventory hooks — matching the firearm/magazine surfaces. - R14. UI is targeted via ARIA roles, accessible names, and visible text — no
data-testid— and meets the project's WCAG 2.2 AA bar.
Export
- R15. CSV export includes ammo as its own row shape, carrying all ammo fields plus
lowStockThresholdand a computed low-stock status column.
Acceptance Evidence#
- AE1. CRUD works end to end for ammo lots, owner-scoped, and shareable via the same grant flow as firearms (integration test).
- AE2. A lot with
quantityRounds <= lowStockThresholdshows the low-stock indicator in the list; one above threshold does not (e2e). - AE3.
/summaryreports both the entries-below-threshold and calibers-below-threshold counts, and the caliber-coverage signal, correctly for a mixed fixture (integration + e2e). - AE4. CSV export contains ammo rows including threshold and low-stock status (integration test).
- AE5. Caliber suggestions and load-type suggestions appear and accept off-list values (e2e).
(R7, R8, R13, R14 decompose the issue's acceptance criteria and are verified at the unit-test level — see U3/U4 test scenarios — rather than each carrying its own AE ID.)
Out of Scope (future-friendly, not built)#
- Consumption / "consume rounds" events and deducting ammo after a range session (the
rangeSession.ammoIdseam stays FK-less until then). - Reservation/allocation of ammo to a trip or event.
- Lot merging in the UI; richer analytics by caliber or load type beyond the R11 low-stock counts and the R12 caliber-coverage signal (e.g., per-caliber consumption trends, historical stock charts, per-load breakdowns).
- Inventory-log participation for ammo (the append-only handling log, #46). Ammo lots get no log entries in this slice: the
inventory_log_parent_type_validCHECK stays('firearm', 'magazine')and the ammo detail view omitsInventoryLogHistory. Deferred, not rejected — a fast-follow can widen that CHECK and add an ammo event-type set.
Open Questions / Assumptions#
(Assumption IDs use the AS prefix to stay distinct from Actor IDs A1–A3.)
- AS1 (assumption). "Reuse existing caliber reference data" means wiring ammo into the existing suggestion union, not a hard FK/enum. Adopt unless a stakeholder wants enforced caliber values (a cross-cutting change to firearms/magazines too, out of scope here).
- AS2 (assumption). By-caliber low-stock (R11) means "at least one lot of that caliber is at or under its own threshold." A "sum of rounds per caliber vs a caliber-level threshold" model would require a new caliber-level threshold concept and is deferred.
- AS3 (assumption, planning).
caliberis the only required text field (matching firearm/magazine);brand,type,notesare optional empty-not-null;grain,quantityRounds,lowStockThresholdare non-negative integers. Flag ifbrandshould be required. - AS4 (confirmed 2026-07-07 by owner). Create-on-behalf is whole-owner trust, not per-parent-type (see KTD7): an existing firearm/magazine create-on-behalf grant also authorizes creating ammo lots for that owner. Confirmed intended —
resolveCreateOwnerstays unscoped.
Planning Contract#
Research Summary#
The owned-parent pattern is implemented twice (firearm, magazine) and is the template to mirror exactly:
- Schema:
src/db/inventory-schema.ts—magazine(lines ~101-125) is the closest analog: uuid PKdefaultRandom(),owner_idtext FK cascade + index, required text (caliber), empty-not-null optional text, nullabledate, integer columns withcheck(...)backstops via the localinList/checkhelpers. Thegranttable (lines ~197-230) hascheck("grant_parent_type_valid", ... in ('firearm', 'magazine')).rangeSession.ammoId(line ~184) is an existing FK-less seam for #7 — no change needed. - Auth:
src/auth/visibility.tsdefinesParentType = "firearm" | "magazine"(line 14) andparentTable()(lines 18-20);src/auth/authorize.tshas its ownparentTable()(lines 13-15).getVisibleIds/resolvePermission/authorizeUpdate/authorizeAndDeleteParent/resolveCreateOwnerare all parent-type-generic — extending the union + both ternaries makes them work for ammo.src/auth/grants.tsconsumesParentType. - Domain:
src/domain/firearms/{constants,validate,service}.ts+__tests__/service.test.tsis the module template.validate.tsreturns a code array (all failures, not first).service.tsusespersistableFields,db.transaction,resolveCreateOwner,authorizeUpdate,authorizeAndDeleteParent,getVisibleIds,resolvePermission. - Reference:
src/domain/reference/reference.ts—distinctCalibers(lines 81-113) unions visible firearm+magazine calibers viagetVisibleIds;calibersForInput(lines 119-126) merges curated ∪ in-use. Ammo calibers join this union. - Summary:
src/domain/summary/summary.ts— purecomputeSummary(firearms, magazines);FirearmIdentityis{id, name}(no caliber today);inventorySummaryloads viaPromise.all. UI:app/(app)/summary/{page.tsx,summary-tables.tsx}. - CSV:
src/domain/csv/{build,serialize}.ts—serializeMagazinesCsvis a pure RFC-4180 serializer with a formula-injection guard (apostrophe-first, then quote);buildInventoryCsvloads + maps; routeapp/api/export/route.ts→magstacker-inventory.csv. Magazine surface has anexport-button.tsx. - UI surface:
app/(app)/magazines/*(page.tsx,magazines-view.tsx,magazine-form.tsx,magazine-detail-view.tsx,[id]/page.tsx,actions.ts,export-button.tsx) is the route template; sharedcomponents/ui/data-table/*,ConfirmDialog,EmptyState/Badge,useDeleteConfirmation,useRowFlash,useTableViewState. Nav inapp/(app)/app-shell.tsx(lines 19-20). Sharing UIapp/(app)/grants/share-control.tsxgates edit viacanGrantEdit = parentType === "firearm"(line 106). - Tests:
src/test-support/factories.ts—makeFirearm/makeMagazinedirect-insert factories; integration tests gate onDATABASE_URL(const live = process.env.DATABASE_URL ? describe : describe.skip) and use the localexpectRejectshelper (bun.rejectsvs drizzle thenables). Migrations viabun run db:generate(drizzle-kit) intosrc/db/migrations/(next sequence0010). - Institutional learnings (
docs/solutions/):test-failures/bun-test-misloads-playwright-e2e-specs.md(run e2e viabun run test:e2e, not rawbun test);runtime-errors/tanstack-autoreset-render-loop-unstable-data.md(memoize data fed touseReactTable) — both apply to the/ammoUI unit.
Key Technical Decisions#
- KTD1 —
ParentTypegains'ammo'in one place, ternaries updated in two. Extend the union insrc/auth/visibility.tsand add theammobranch toparentTable()there and insrc/auth/authorize.ts. This is the minimal change that makes all shared auth work for ammo. The grant CHECK constraint is widened in the same migration as the ammo table. - KTD2 — Ammo mirrors
magazine's schema shape, with three integer CHECK backstops (grain >= 0,quantity_rounds >= 0,low_stock_threshold >= 0). No CHECK ontype(free text).acquired_datenullable = unset. - KTD3 — Low-stock is derived, never stored. A pure
isLowStock(ammo)predicate (quantityRounds <= lowStockThreshold) is the single source of truth, consumed by the list badge, summary roll-ups, and CSV status column. Mirrors howeffectiveCapacity/Lifetime Totalare derived. - KTD4 — Ammo sharing is edit-capable (firearm-model), not view-only (magazine-model).
share-control.tsxcanGrantEditbecomesparentType === "firearm" || parentType === "ammo". Serves the club/range shared-stock use case and matches Actor A2. - KTD5 — Ammo exports as a dedicated CSV, not merged into the magazine CSV. New
serializeAmmoCsv+buildAmmoCsv+/api/export/ammoroute + an ammoexport-button, mirroring the magazine export stack. Different column shape makes a shared table wrong; a parallel stack keeps both serializers pure and simple. - KTD6 —
computeSummaryis extended, not forked. It gains anammoparameter and returnsammoEntriesLow,ammoCalibersLow, andcaliberCoverage. Firearm caliber is threaded in by adding an optionalcaliberfield to theFirearmIdentityinput — optional deliberately, so the existingsummary.test.tsliterals (which pass{id, name}only) keep compiling; a required field would reddenjust ci-checkon a file this plan doesn't touch. New ammo coverage tests populatecaliber. Keeping one aggregation function avoids a second load path. - KTD7 — create-on-behalf is whole-owner trust, inherited unchanged.
resolveCreateOwner(src/auth/authorize.ts) authorizes create-on-behalf on any active edit grant withallowCreateOnBehalffrom owner to actor — its query has noparentType/parentIdfilter. This is existing firearm/magazine behavior; ammo inherits it, so an existing create-on-behalf grant will also authorize creating ammo lots for that owner. Treated as intentional (create-on-behalf is a per-owner relationship, not per-entity-type) and recorded as AS4, confirmed by the owner on 2026-07-07. Do not silently rely on "becomes ammo-capable automatically" as if it were safe-by-construction.
High-Level Technical Design#
Data model — ammo as a third owned parent alongside the existing polymorphic grant:
Summary aggregation flow (R11/R12) — all derived in-memory over the viewer-relative visible snapshot:
Directional guidance for reviewers — not implementation specification.
Output Structure#
New files (existing files modified in-place are listed per unit, not here):
src/domain/ammo/
├── constants.ts # COMMON_AMMO_TYPES suggestion list (no CHECK)
├── validate.ts # validateAmmo (code array) + isLowStock predicate
├── service.ts # CRUD: create/update/delete/get/listAmmo
└── __tests__/
└── service.test.ts # integration (DATABASE_URL-gated)
app/(app)/ammo/
├── page.tsx # server component: listAmmo + suggestions + permissions
├── ammo-view.tsx # client list (DataTable + low-stock badge)
├── ammo-form.tsx # create/edit form (caliber + load-type datalist inputs)
├── ammo-detail-view.tsx # detail + share-control + delete
├── export-button.tsx # hits /api/export/ammo
├── actions.ts # server actions (create/update/delete)
└── [id]/
└── page.tsx # detail route
app/api/export/ammo/
└── route.ts # ammo CSV download
src/domain/csv/
├── ammo-serialize.ts # serializeAmmoCsv (pure, injection-guarded)
└── ammo-build.ts # buildAmmoCsv (loads visible ammo)
Implementation Units#
U1. Ammo table, grant CHECK, and migration#
- Goal: Add the
ammotable and widen thegrantparent-type CHECK so ammo can be owned and shared at the DB layer. - Requirements: R1, R2, R3, R8.
- Dependencies: none.
- Files:
src/db/inventory-schema.ts(addammopgTable; widengrant_parent_type_validCHECK toin ('firearm', 'magazine', 'ammo')); newsrc/db/migrations/0010_*.sql(generated). - Approach: Mirror
magazine. Columns:iduuid PKdefaultRandom();ownerIdtext FK →usercascade +ammo_owner_id_idx;brand/type/notestextnotNull().default("");calibertextnotNull();grain/quantityRounds/lowStockThresholdintegernotNull().default(0);acquiredDatedate nullable;createdAt/updatedAttimestamps. CHECK backstops viainList/check:ammo_grain_min(>= 0),ammo_quantity_min(>= 0),ammo_threshold_min(>= 0). Generate the migration withbun run db:generate; verify it creates the table and alters the grant CHECK (drop+add), then applies withbun run db:migrate. - Patterns to follow:
magazinedefinition and thegrantCHECK block insrc/db/inventory-schema.ts. - Test scenarios:
Test expectation: none — schema/migration unit; behavior is covered by U3 service integration tests and a migrate-clean check.Verifybun run db:migrateapplies cleanly against a fresh Testcontainers Postgres. - Verification: Migration applies on a clean DB; the
ammotable and widened grant CHECK exist;bun run typecheckpasses.
U2. Extend ParentType to ammo across the auth layer#
- Goal: Make the shared visibility/authorization functions treat ammo as a first-class owned parent.
- Requirements: R3, R4.
- Dependencies: U1.
- Files:
src/auth/visibility.ts(union+ "ammo";parentTable()ammo branch);src/auth/authorize.ts(parentTable()ammo branch); newsrc/auth/__tests__/visibility-ammo.test.ts(or extend existing auth test). - Approach: Change
ParentTypeto"firearm" | "magazine" | "ammo"; bothparentTable()ternaries returnammofor the new case. No other auth logic changes —getVisibleIds,resolvePermission,authorizeUpdate,authorizeAndDeleteParent,resolveCreateOwnerbecome ammo-capable automatically. - Patterns to follow: existing
parentTable()in both files. - Test scenarios:
getVisibleIds(db, owner, "ammo")returns owned ammo IDs. Happy path.- A
view-granted ammo lot appears in the grantee's visible set; a non-granted lot does not. Integration (grant crosses layers). resolvePermissionreturnsownerfor the owner,edit/viewper grant,nullwhen outside the visible set. Covers AE1.authorizeUpdatefor parentTypeammo: owner and edit-grantee pass; a view-grantee is rejected (NotAuthorizedError); an outsider getsNotFoundError. Guards the edit-capable-sharing boundary (KTD4/R4).authorizeAndDeleteParent(actorId, "ammo", id)deletes the owner's lot; called by a view-grantee'sactorIdit throwsNotAuthorizedError. Error path. (Signature:(actorId, parentType, parentId, database?)— it manages its own transaction.)- Regression (blast radius): the inventory-log write/validate path still rejects
parentType: "ammo"(invalidParentType) after theParentTypeunion widens — ammo never reaches the firearm/magazine log branches insrc/domain/inventory-log/*. Guards KTD1.
- Verification: Auth tests pass for the
ammoparent type; firearm/magazine auth tests still pass.
U3. Ammo domain module (constants, validate, service, factory)#
- Goal: Full CRUD domain for ammo with validation and low-stock derivation, plus caliber reference wiring.
- Requirements: R1, R2, R5, R6, R7, R8, R9.
- Dependencies: U1, U2.
- Files: new
src/domain/ammo/constants.ts,src/domain/ammo/validate.ts,src/domain/ammo/service.ts,src/domain/ammo/__tests__/service.test.ts;src/domain/reference/reference.ts(union ammo calibers intodistinctCalibers);src/test-support/factories.ts(addmakeAmmo). - Approach:
constants.tsexportsCOMMON_AMMO_TYPES = ["FMJ","JHP","HP","Match","Soft Point","Subsonic"](suggestions only, no CHECK).validate.ts:validateAmmo(input)returns a code array —emptyCaliberwhen caliber blank,negativeGrain/negativeQuantity/negativeThresholdfor< 0— plus a pureisLowStock(ammo)(quantityRounds <= lowStockThreshold, KTD3).service.tsmirrorssrc/domain/firearms/service.ts:Ammo = typeof ammo.$inferSelect,AmmoCreateInputwithownerId?,persistableFields,createAmmo/updateAmmo/deleteAmmo/getAmmo/listAmmousing the shared auth helpers withparentType "ammo";listAmmoorders by caliber, then brand, then grain. ExtenddistinctCalibersto also union visible ammo calibers viagetVisibleIds(db, userId, "ammo"). - Execution note: Implement new domain behavior test-first — start with a failing
createAmmo+validateAmmocontract test. - Patterns to follow:
src/domain/firearms/{validate,service}.ts;makeMagazineinsrc/test-support/factories.ts;distinctCalibersunion inreference.ts. - Test scenarios:
validateAmmoreturns["emptyCaliber"]for blank caliber;[]for a valid lot; all negative-number codes together when grain, quantity, and threshold are all< 0. Happy + edge.isLowStock:quantity == threshold→ true;quantity == threshold + 1→ false;quantity < threshold→ true. Boundary. Covers AE2.createAmmopersists a lot owned by the actor;getAmmoreturns it withpermission: "owner". Covers AE1.- Two lots with identical brand/caliber/type/grain both persist as distinct rows. Covers R7.
createAmmowith an active edit + create-on-behalf grant persists under the owner; without the opt-in it is rejected. Integration, covers R4/AE1.updateAmmo/deleteAmmoon a non-visible lot raiseNotFoundError. Error path.listAmmoreturns only visible lots, ordered by caliber then brand. Happy path.distinctCalibersincludes a caliber that exists only on an ammo lot. Integration, covers R5.
- Verification:
bun testgreen for the ammo domain;just ci-checkpasses.
U4. /ammo route and UI#
- Goal: The full ammo inventory surface — list with low-stock indicator, create/edit form with suggestions, detail with sharing and delete, nav entry.
- Requirements: R6, R10, R13, R14, R4.
- Dependencies: U3.
- Files: new
app/(app)/ammo/{page.tsx,ammo-view.tsx,ammo-form.tsx,ammo-detail-view.tsx,actions.ts,export-button.tsx}andapp/(app)/ammo/[id]/page.tsx;app/(app)/app-shell.tsx(add{ href: "/ammo", label: "Ammo" }nav entry);app/(app)/grants/share-control.tsx(canGrantEditincludes"ammo", KTD4);app/(app)/grants/actions.ts(addrevalidatePath("/ammo")toshareItemAction/revokeGrantAction). - Approach: Mirror
app/(app)/magazines/*.page.tsx(server) loadslistAmmo+calibersForInput+COMMON_AMMO_TYPES+ permissions, builds anAmmoListItem[].ammo-view.tsxuses the sharedDataTable/hooks and renders a low-stock indicator as aBadgewithtone="destructive"and the visible text "Low stock" whenisLowStock— a text label, never color alone, honoring DESIGN.md's One Accent Rule and PRODUCT.md's "color is never the sole carrier of meaning".ammo-form.tsxreuses the exact native<input list="…"><datalist>suggestion mechanism frommagazine-form.tsxfor both caliber and load type (load type seeded withCOMMON_AMMO_TYPES); suggestions are computed server-side, so no ARIA-combobox widget, async loading, or no-match state is introduced. Both fields accept off-list values.actions.tsfollowsrequireUserId()→ domain call →revalidatePath("/ammo")→ActionResult. Delete viauseDeleteConfirmation+ConfirmDialog. Toasts use on-voice copy (e.g. "Lot logged" on create, "Changes saved" on edit, "Lot removed" on delete), mirroringfirearm-form.tsx's "Firearm logged" /magazine-form.tsx's "Magazine seated"; the empty state gets an on-voice title/description (e.g. "No ammo on hand" / "Add a lot to track rounds and low-stock alerts"). Detail view embedsshare-control(now edit-capable for ammo). Deliberately omitInventoryLogHistory(whichmagazine-detail-view.tsxembeds unconditionally) — ammo is out of the inventory-log slice (see Out of Scope). Wiring it would routeparentType="ammo"into the binaryParentTypebranches insrc/domain/inventory-log/*, which fall through to the magazine branch (a silent mislabel); and persisting an ammo log entry would additionally violate theinventory_log_parent_type_validCHECK. Neither is wanted — omit the component. - Execution note: Prefer Playwright e2e for list/badge/form behavior over brittle markup assertions; memoize the data fed to
useReactTable(seedocs/solutions/runtime-errors/tanstack-autoreset-render-loop-unstable-data.md). - Patterns to follow:
magazines-view.tsx,magazine-form.tsx,magazine-detail-view.tsx,hooks/use-delete-confirmation.ts,components/ui/data-table/*,components/ui/feedback(EmptyState,Badge). - Test scenarios:
- Empty inventory renders the empty state; adding a lot shows it in the list. e2e, covers R13.
- A lot with
quantity <= thresholdshows the low-stock indicator; one above threshold does not. e2e, covers AE2/R10. - Caliber and load-type suggestion fields show suggestions and accept a typed off-list value. e2e, covers AE5/R5/R6.
- Delete opens
ConfirmDialog; confirming removes the lot and toasts success. e2e, covers R13. - The form/list are reachable and operable by keyboard, targeted via ARIA roles / accessible names (no
data-testid). a11y, covers R14. - The ammo detail offers an edit grant (share-control), unlike magazines. e2e, covers R4/KTD4.
- Verification:
bun run test:e2egreen for the/ammojourneys; nav shows Ammo; a11y checks pass.
U5. Summary roll-ups and caliber-coverage cross-reference#
- Goal: Extend the summary to report both low-stock roll-ups and the firearm/ammo caliber-coverage signal.
- Requirements: R11, R12.
- Dependencies: U3.
- Files:
src/domain/summary/summary.ts(extendcomputeSummary+inventorySummary+ theSummarytype; add optionalcalibertoFirearmIdentity);src/domain/summary/__tests__/*(add ammo cases — existingFirearmIdentityliterals stay valid becausecaliberis optional);app/(app)/summary/{page.tsx,summary-tables.tsx}(render new sections); newe2e/ammo-summary.spec.ts(Playwright/summaryroll-ups). - Approach (KTD6): Add an
ammoparameter tocomputeSummaryand thread firearmcaliberinto its firearm input. ComputeammoEntriesLow(count of lots whereisLowStock),ammoCalibersLow(distinct calibers among low lots), andcaliberCoverage(calibers present on the owner's firearms where on-hand ammo for that caliber is zero or only-low).inventorySummaryaddslistAmmoto itsPromise.all. Update the/summarypage.tsxEmptyState gate — currentlytotalMagazines === 0 && firearmCounts.length === 0— to also account for ammo, so an ammo-only inventory renders the roll-ups instead of the empty state. RenderammoEntriesLow/ammoCalibersLowas plainStatcards (deliberate — informational counts, no severity color, honoring the One Accent Rule) and rendercaliberCoverageas aDataTablewith aReasoncolumn ("No ammo" vs "Low stock only") so the R12 zero-vs-only-low distinction is visible to the user, not just computed. - Patterns to follow: existing
computeSummarypure-aggregation shape;summary-tables.tsxrendering. - Test scenarios:
- Mixed fixture: 3 low lots across 2 calibers →
ammoEntriesLow == 3,ammoCalibersLow == 2. Covers AE3/R11. - A caliber with a firearm and zero ammo lots appears in
caliberCoverage; a caliber with ample ammo does not; a caliber whose lots are all low appears. Covers AE3/R12. - Any-vs-all divergence: a caliber with one low lot AND one adequate lot appears in
ammoCalibersLow(R11) but is NOT flagged incaliberCoverage(R12). Covers R11/R12. - Empty ammo inventory yields zero counts and empty coverage (never null). Edge.
- Ammo-only inventory (zero firearms and magazines) still renders the roll-up section, not the EmptyState. Covers R11/F1.
- Magazine/firearm summary fields are unchanged by the ammo extension. Regression.
- e2e:
/summaryrenders both roll-up counts and the coverage list for a seeded mixed fixture. Covers AE3 (e2e leg).
- Mixed fixture: 3 low lots across 2 calibers →
- Verification: Summary tests pass;
/summaryrenders both counts and the coverage list for a seeded mixed fixture.
U6. Ammo CSV export#
- Goal: A dedicated ammo CSV export including threshold and computed low-stock status.
- Requirements: R15.
- Dependencies: U3.
- Files: new
src/domain/csv/ammo-serialize.ts,src/domain/csv/ammo-build.ts,app/api/export/ammo/route.ts,src/domain/csv/__tests__/ammo-serialize.test.ts; wireapp/(app)/ammo/export-button.tsx(from U4) to/api/export/ammo. - Approach (KTD5):
serializeAmmoCsvmirrorsserializeMagazinesCsv— pure, RFC-4180, formula-injection guard (apostrophe-first then quote), trailing newline, header-only on empty. Headers:Brand, Caliber, Type, Grain, Quantity Rounds, Low Stock Threshold, Low Stock, Acquired Date, Notes—Low StockisisLowStock(row) ? "Yes" : "No".buildAmmoCsv(actorId)loads visible ammo vialistAmmo. Route re-resolves the session in-handler (401 with no body when unauthenticated), returnstext/csvwithContent-Disposition: attachment; filename="magstacker-ammo.csv". - Patterns to follow:
src/domain/csv/serialize.ts,src/domain/csv/build.ts,app/api/export/route.ts. - Test scenarios:
- Empty list emits exactly one header row. Edge, covers R15.
- A low lot serializes
Low Stock=Yes; an above-threshold lot =No. Covers AE4. notes/brandvalues beginning with each injection prefix (=,+,-,@, tab, CR) are apostrophe-guarded then quoted — full guard parity with the magazine serializer, not just=. Security/edge.thresholdand numeric columns serialize as plain integers. Happy path.- Unauthenticated GET to
/api/export/ammoreturns 401. Error path.
- Verification: Serializer tests pass; the ammo export button downloads a well-formed CSV with the status column.
U7. CONCEPTS.md and vocabulary#
- Goal: Record the Ammo entity and the shift from two owned parents to three in the domain glossary.
- Requirements: supports the whole feature's shared vocabulary.
- Dependencies: U1–U6 (land the doc change with the feature, not before it is real).
- Files:
CONCEPTS.md. - Approach: Add an Ammo entry under "Inventory entities" (an owned lot: brand, caliber, load
type, grain,quantityRounds,lowStockThreshold, optional acquired date and notes; separate lots; low-stock is derived whenquantityRounds <= lowStockThreshold). Update the "Relationships" and Firearm/Magazine framing from "two owned parents" to three, and note ammo joins the grant sharing model. Add Low Stock as a derived-value term. Follow the format of existing entries. - Test scenarios:
Test expectation: none — documentation. - Verification: CONCEPTS.md reads consistently; no stale "two owned parents" phrasing remains.
System-Wide Impact#
- Grant model: ammo becomes a third shareable parent;
share-controlgains ammo edit grants. Existing firearm/magazine grants are untouched. - Reference data:
distinctCalibers/calibersForInputnow span three entities — ammo calibers surface as suggestions everywhere those functions feed. - Summary contract: the
Summaryshape grows new fields; consumers ofcomputeSummary/inventorySummary(onlyapp/(app)/summary/*) must handle them. - Export surface: a new
/api/export/ammoroute joins the existing/api/export. - Inventory-log: intentionally NOT extended to ammo this slice — its
parent_typeCHECK and event-type gate stayfirearm | magazine(see Out of Scope). The widenedParentTypeunion is accepted at those binary branches only by the existing runtime guard, locked by a U2 regression test. - Docs: CONCEPTS.md and, on merge, the AGENTS.md Dosu mirror should reflect the new entity if that surface enumerates entities.
Risks & Dependencies#
- Grant CHECK alteration (U1): widening
grant_parent_type_validis a drop+add in the generated migration — verify drizzle-kit emits a plainDROP CONSTRAINT+ADD CONSTRAINT(not a table rewrite) and it applies cleanly against a Testcontainers Postgres seeded with pre-existing firearm/magazine grant rows. ParentTypeblast radius (U2): widening the union does NOT raise a compile error at the inventory-log binaryparentTypebranches (src/domain/inventory-log/{service,constants}.ts), which would treatammoasmagazine. The existing runtimeinvalidParentTypeguard fails closed; U2 adds a regression test locking that in, and U4 omits the inventory-log UI for ammo. Do not wire ammo into inventory-log this slice.- Summary input change (U5):
caliberis added as an optional field onFirearmIdentity, so existingsummary.test.tsliterals keep compiling andjust ci-checkstays green; a required field would break it. New coverage tests populatecaliber. - create-on-behalf reach (KTD7/AS4): existing firearm/magazine create-on-behalf grants gain ammo-creation power on ship. Confirmed intended by the owner (2026-07-07);
resolveCreateOwnerstays unscoped. - e2e harness (U4): run via
bun run test:e2e(Docker required), never rawbun test— seedocs/solutions/test-failures/bun-test-misloads-playwright-e2e-specs.md. - Dependency chain: U1 → U2 → U3 gate everything; U4/U5/U6 are parallelizable after U3; U7 lands last.
Verification Contract#
just ci-checkpasses (biome lint,bun tsc --noEmit,bun test) — the mandatory pre-commit gate.- Integration tests (DATABASE_URL-gated, Testcontainers) cover U2/U3/U5/U6 domain behavior and satisfy AE1, AE3, AE4.
- Playwright e2e (
bun run test:e2e) covers the/ammojourneys and the/summaryroll-ups + coverage list, satisfying AE2, AE3 (e2e leg), AE5. bun run db:migrateapplies the new migration cleanly on a fresh database.- a11y:
/ammosurfaces meet WCAG 2.2 AA; UI targeted via ARIA/roles/text, nodata-testid.
Definition of Done#
- All seven units landed; AE1–AE5 demonstrably satisfied by tests.
just ci-checkgreen; new migration applies clean.- Ammo is creatable, editable, deletable, owner-scoped, and edit-shareable via the existing grant UI.
/summaryshows both low-stock roll-ups and the caliber-coverage signal.- Ammo CSV export works with threshold + status columns.
- CONCEPTS.md updated; no stale "two owned parents" phrasing.
Sources & Research#
- GitHub issue #7 — acceptance criteria and open questions (all resolved).
- Requirements brainstorm: this file's Product Contract (
product_contract_source: ce-brainstorm). - Codebase patterns:
src/db/inventory-schema.ts,src/auth/{visibility,authorize,grants}.ts,src/domain/{firearms,magazines,reference,summary,csv}/*,app/(app)/{magazines,summary,grants}/*,src/test-support/factories.ts. - Institutional learnings:
docs/solutions/test-failures/bun-test-misloads-playwright-e2e-specs.md,docs/solutions/runtime-errors/tanstack-autoreset-render-loop-unstable-data.md. - No external research required — the owned-parent pattern is implemented twice locally.