inventory — Module Spec
Authoritative live recount — Stock Transfer schema lock, 2026-07-16 task run: 29 base tables / 425 base-table columns / 1 view. The current pre-Transfer baseline was 26/362; Transfer adds exactly 3/63. The view's eight columns are excluded from base-table arithmetic.
Stock Transfer schema capability — schema-only
Inventory now owns the complete schema contract for stock transfers:
transfer(26 columns),transfer_line(21), and append-onlytransfer_reconciliation_event(16).- One atomic shipment per Transfer; every effective line ships its full approved quantity. Multiple physical shipment batches require separate Transfers. Partial receipts remain supported.
- One optional durable lot per line. Multi-lot quantities require multiple lines; duplicate variant/location/lot lines remain valid and isolated by line UUID.
- Approval creates protected aggregate reservations. Shipment locks and decrements exact source stock and stock-lot grains and records one exact outbound movement line per Transfer line.
- Receipt copies immutable outbound
unit_cost_centsand the samelot_id; cumulative fractional allocation reconciles integer-cent cost exactly. - Source sites may be active, inactive, or closed but not soft-deleted. Destinations must be active and nondeleted at approval and shipment. Receipt continues after post-shipment destination deactivation.
- Referenced sites, inventory locations, and lots cannot be soft-deleted while the Transfer is nonterminal.
- Consequential commands are human-only in this schema design and remain dormant to every runtime role until a separately approved non-spoofable credential gateway exists.
Exact live Transfer shape: 3 tables / 63 columns / 28 CHECKs / 20 FKs / 11 triggers. Exact complete Inventory shape: 29 tables / 425 columns / 90 CHECKs / 138 FKs / 164 indexes / 46 policies / 41 non-internal triggers; 29/29 tables RLS-enabled.
1. Purpose
inventory owns the tenant's record of what it stocks and sells: items, variants, taxonomy (categories/tags), barcodes/images, the option/variant structure behind SKUs, per-location stock levels and movements, reservations, cycle counts, lots (for perishable/plant tracking), kit/BOM composition, and the item dedup/merge lifecycle. It is the biggest module built so far and the first real consumer of shared.plant — item.plant_id is the first live FK from a product module into the botanical taxonomy shared seeded 114 rows for. inventory is module #12 in the v2 pipeline and the second of the 13 nursery-vertical product modules to go through design → build, following crm.
2. Ownership
Owns — 29 tables across nine groups:
CATALOG (9): item, item_variant, category, item_category, tag, item_tag, barcode, item_image, brand (added 2026-07-20, Gap-Fill Batch — see §9 DR-70).
VARIANT STRUCTURE (2): option_type, variant_option.
LOCATIONS (1): inventory_location.
STOCK (7): stock, stock_movement, stock_movement_line, stock_reservation, stock_adjustment_reason, stock_adjustment_request, stock_adjustment_batch (added 2026-07-10, Header/Line Remediation fix #5).
TRANSFERS (3): transfer, transfer_line, transfer_reconciliation_event (schema-locked 2026-07-16; no service/runtime wiring).
COUNTS (2): stock_count, stock_count_line.
LOTS (2): lot, stock_lot.
KITS (1): kit_component.
MERGE (2): item_merge_candidate, item_merge.
Does NOT own:
- Actual sale price resolution and markdown execution → Pricing (not yet built in v2).
inventoryownsitem_variant.base_price_cents(list anchor) andavg_cost_cents(weighted-average costing) only; it can flag dead/aging stock as a signal, but never writes a markdown itself. - Purchase order receiving workflow → Purchasing (not yet built in v2).
stock_movement.source_modulealready anticipates a futurereceiveStock(source_module='purchasing')call; no live seam row exists yet since Purchasing isn't built. - File/photo storage → Files (not yet built in v2 — verified live via
psql \dn, thefilesschema does not exist).inventoryholds only one deferred bridge column,stock_movement.photo_ref(plain nullableuuid, no FK). - POS/e-commerce sale capture → POS/Orders (not yet built in v2).
inventoryrecords the resultingstock_movementviamay_act_alonesystem automation; it does not own the sale transaction itself.
3. Layer & Dependencies
Tenant-scoped operational layer, second product-tier module.
Depends on:
platform—platform.tenantis the FK target for every tenant-scopedinventorytable;platform.set_updated_at()trigger (on the 19 tables that carryupdated_at).shared(module #3) —item.plant_id → shared.plant.id(nullable FK,ON DELETE SET NULL) isinventory's headline new seam and the first live consumer ofshared.plant; alsoshared.unit_of_measure.codeforsell_uom_code/stock_uom_code/purchase_uom_code/weight_uom_code(onitem_variant) andcapacity_uom_code(oninventory_location);shared.currency.iso_codeforitem_variant.currency_code.multi_loc(module #4) —multi_loc.siteFK oninventory_location.site_id,stock.site_id,stock_movement.site_id,stock_reservation.site_id,stock_count.site_id,stock_lot.site_id,stock_adjustment_request.site_id— all enforced, not a narrow exception likecrm's; site-scoping is core to howinventoryworks, not an add-on.identity(module #2) — now a dependency, added relative to the pre-existing 21/238 baseline row. Every actor-attribution column (created_by_actor_id,updated_by_actor_id,performed_by_actor_id,started_by_actor_id,reconciled_by_actor_id,reviewed_by_actor_id,proposed_by_actor_id,merged_by_actor_id,counted_by_actor_id, and more) FKs toidentity.actor, notidentity.identity_user. Prospectively depends onidentity.agent_duty_grantfor autonomy enforcement (schema-only consumer this pass — see §7/§8).
Depended on by: as of 2026-07-10, the new receiving module (extracted out of purchasing this same day, see §9 DR-69) is a REAL, live consumer — receiving.goods_receipt_line carries composite FKs into item_variant, lot, stock_movement, and stock_movement_line; the UNIQUE(id, tenant_id) prerequisite on all four is this same reopen's own addition. Pricing (base price / cost anchors) remains a flagged-but-not-built seam. Future modules expected to reference inventory.item/item_variant/stock include POS/Orders (sale capture), Pricing (markdown execution), and Reporting (dead-stock/aging analytics off stock.last_movement_at).
4. Tables
25 tables, 351 columns, +1 view (335 at 2026-07-06 lock; +1 from the 2026-07-07 reopen's stock_reservation.created_by_actor_id; +1 from the 2026-07-08 Remediation Phase 1 pass's stock_count.reconciled_at; Remediation Phase 3 added no columns — see §9 DR-66; +1 from the 2026-07-08 Remediation Phase 4 pass's stock.last_movement_id, plus a new VIEW, stock_reconciliation_shell — the codebase's first — which does NOT count toward the table total, see §9 DR-67; +1 table / +13 columns from the 2026-07-10 Header/Line Remediation reopen — fixes #5, #9, see §9 DR-68; +0 tables / +0 columns from a same-day companion 2026-07-10 reopen — a pure constraint-shape addition (UNIQUE(id, tenant_id) on item_variant, lot, stock_movement, stock_movement_line), required as a composite-FK prerequisite by the new receiving module's extraction out of purchasing, see §9 DR-69), locked 2026-07-06, reopened 2026-07-07, twice on 2026-07-08, and twice on 2026-07-10. See packages/db/migrations/20260706070000_inventory_module.sql for the full DDL, plus packages/db/migrations/20260709060000_phase4_item20_outbox_and_stock_reconciliation.sql (part b) for the Phase 4 addition, packages/db/migrations/20260710030000_headerline_inventory_fixes.sql for the Header/Line Remediation reopen, and packages/db/migrations/20260710090000_receiving_extraction.sql for the same-day companion Receiving-extraction reopen. Drizzle schema files: packages/db/src/schema/inventory/{_schema,catalog,variant_structure,location,stock,count,lot,kit,merge,index}.ts.
| Table | Group | PK style | Notes |
|---|---|---|---|
item |
Catalog | UUID | Core entity, tenant-scoped, RLS enabled, soft-delete, plant_id nullable FK → shared.plant |
item_variant |
Catalog | UUID | Subordinate to item, RLS enabled, soft-delete; gained UNIQUE(id, tenant_id) 2026-07-10 (Receiving-extraction composite-FK prerequisite, §9 DR-69) |
category |
Catalog | UUID | Tenant-scoped taxonomy, soft-delete |
item_category |
Catalog | UUID | Join table, hard-delete (no updated_at) |
tag |
Catalog | UUID | Tenant-scoped, soft-delete |
item_tag |
Catalog | UUID | Join table, hard-delete (no updated_at) |
barcode |
Catalog | UUID | Subordinate to item_variant, soft-delete |
item_image |
Catalog | UUID | Subordinate to item, soft-delete |
brand |
Catalog | UUID | NEW 2026-07-20 (Gap-Fill Batch, pricing A1 prerequisite) — minimal tenant-scoped name catalog, soft-delete; no slug/description/hierarchy, see §9 DR-70 |
option_type |
Variant structure | UUID | Tenant-scoped catalog (e.g. pot_size, color), soft-delete |
variant_option |
Variant structure | UUID | Option value × item_variant linkage, soft-delete |
inventory_location |
Locations | UUID | Subordinate to multi_loc.site, RLS enabled, soft-delete |
stock |
Stock | UUID | Per-(variant, location) balance, RLS enabled, last_movement_at cache |
stock_movement |
Stock | UUID | Append-only (no updated_at, no deleted_at); gained UNIQUE(id, tenant_id) 2026-07-10 (Receiving-extraction composite-FK prerequisite, §9 DR-69) |
stock_movement_line |
Stock | UUID | Append-only (no updated_at, no deleted_at); gained UNIQUE(id, tenant_id) 2026-07-10, closing the gap DR-68 had disclosed as open (§9 DR-69) |
stock_reservation |
Stock | UUID | Tenant-scoped, RLS enabled, soft-delete |
stock_adjustment_reason |
Stock | UUID | Tenant-scoped catalog, soft-delete |
stock_adjustment_request |
Stock | UUID | Mutable pending→approved/rejected lifecycle, no soft-delete; gained batch_id 2026-07-10 |
stock_adjustment_batch |
Stock | UUID | NEW 2026-07-10 (Header/Line Remediation fix #5) — header grouping multiple stock_adjustment_request rows, RLS enabled, soft-delete, updated_at NOT trigger-maintained (disclosed gap, see §9 DR-68/§10) |
stock_count |
Counts | UUID | Tenant-scoped, RLS enabled, soft-delete |
stock_count_line |
Counts | UUID | Subordinate to stock_count, soft-delete; gained reconciled_at/reconciled_by_actor_id + a bespoke conditional-immutability trigger 2026-07-10 |
lot |
Lots | UUID | Tenant-scoped, soft-delete; gained UNIQUE(id, tenant_id) 2026-07-10 (Receiving-extraction composite-FK prerequisite, §9 DR-69) |
stock_lot |
Lots | UUID | Per-(lot, location) balance, RLS enabled, soft-delete |
kit_component |
Kits | UUID | BOM line, item_variant self-referential, soft-delete |
item_merge_candidate |
Merge | UUID | NEW — mutable pending→approved/rejected lifecycle, no soft-delete |
item_merge |
Merge | UUID | NEW — append-only post-execution audit (no updated_at, no deleted_at) |
| Total | 351 cols | ||
stock_reconciliation_shell |
VIEW (not a table) | — | Added 2026-07-08 (Remediation Phase 4) — the codebase's first CREATE VIEW; not counted in the table/column totals above. See §9 DR-67. |
item and item_variant
Core catalog entities. item carries item_type (CHECK includes plant/hard_good/etc.) and the new plant_id (nullable uuid, FK → shared.plant.id, ON DELETE SET NULL), gated by chk_item_plant_id_item_type: plant_id IS NULL OR item_type = 'plant' — a hard_good item can never carry a plant_id. item_variant carries the retyped UOM columns (sell_uom_code, stock_uom_code, purchase_uom_code, weight_uom_code, all text, FK → shared.unit_of_measure.code) and currency_code (FK → shared.currency.iso_code, char(3), ON DELETE RESTRICT). Both carry search_vector as a real GENERATED ALWAYS AS (to_tsvector(...)) STORED column with a GIN index (item_search_vector_idx, item_variant_search_vector_idx) — kept, not deferred, since a generated tsvector column has no dependency on a search schema existing. item_variant gained UNIQUE(id, tenant_id) 2026-07-10 — a composite-FK prerequisite for the new receiving module, see §9 DR-69.
category, item_category, tag, item_tag
Taxonomy join tables. item_category/item_tag are hard-delete (no updated_at, no deleted_at) — matches v1's convention for pure join tables.
barcode, item_image
Subordinate one-to-many tables off item_variant/item.
brand
NEW 2026-07-20 (Gap-Fill Batch, architect-authorized minimal build — PROJECT_DECISIONS #74). A tenant-scoped brand/manufacturer name catalog (id/tenant_id/name/timestamps/soft-delete only) built solely as the composite-FK prerequisite for Pricing's brand-scoped price rules (pricing.price_rule.brand_id); inventory itself does no brand-scoped browsing, filtering, or hierarchy — see the pricing module spec for that capability. See §9 DR-70 for why the shape is deliberately minimal and what a fuller brand-management feature would need.
option_type, variant_option
The variant-structure pair behind SKU generation (e.g. pot_size → 4in/6in/1gal).
inventory_location
Subordinate to multi_loc.site (site_id FK, enforced). Carries capacity_uom_code (FK → shared.unit_of_measure.code, ON DELETE SET NULL).
stock, stock_movement, stock_movement_line, stock_reservation, stock_adjustment_reason, stock_adjustment_request
stock is the per-(variant, location) balance row; carries the new last_movement_at (timestamptz, nullable) maintained cache, plus a second reconciliation-watermark column, last_movement_id (nullable UUID, FK → stock_movement.id), added 2026-07-08 (Remediation Phase 4, Item 20b) — see §9 DR-67. stock_movement/stock_movement_line are append-only (no updated_at at all, by design — an immutable ledger). stock_movement carries the new photo_ref (plain nullable uuid, no FK — deferred forward-ref to the not-yet-built Files module) and actor columns retargeted to identity.actor: performed_by_actor_id (was performed_by in v1). stock_movement.movement_type's CHECK was widened 2026-07-08 (Remediation Phase 3) to add 'produced' — see §9 DR-66. stock_movement gained UNIQUE(id, tenant_id) 2026-07-10 — a composite-FK prerequisite for the new receiving module, see §9 DR-69. stock_movement_line carries data_source/is_verified using the established ('seed','ai_generated','manual') vocabulary; it gained UNIQUE(id, tenant_id) later on 2026-07-10, via the same-day companion Receiving-extraction reopen — closing the gap DR-68 had disclosed as open earlier that same day, see §9 DR-69. stock_adjustment_request is the mutable propose/review/execute table — see §9 DR-51/DR-52 for its dedup fix — carries estimated_impact_cents (bigint, nullable), and gained a nullable batch_id composite FK 2026-07-10 (Header/Line Remediation fix #5) into the new stock_adjustment_batch header table (grouping multiple related requests for joint review) — see §9 DR-68. A new VIEW, stock_reconciliation_shell (added 2026-07-08, Remediation Phase 4, Item 20b), joins stock to stock_movement via last_movement_id — see §9 DR-67 for full detail, including why it ships as a deliberately minimal shell.
stock_count, stock_count_line
Cycle-count workflow. started_by_actor_id/reconciled_by_actor_id retargeted to identity.actor (were started_by/reconciled_by in v1). stock_count_line carries the new counted_by_actor_id for per-line counter attribution, and gained reconciled_at/reconciled_by_actor_id + a bespoke conditional-immutability trigger 2026-07-10 (Header/Line Remediation fix #9) — see §9 DR-68.
lot, stock_lot
Lot/batch tracking, primarily for perishable and plant-material inventory. stock_lot carries the new condition_grade (text, CHECK A/B/C/cull) — closes a pre-pivot v1 feature spec item that was never built (see DR-59). lot gained UNIQUE(id, tenant_id) 2026-07-10 — a composite-FK prerequisite for the new receiving module, see §9 DR-69.
kit_component
BOM (bill of materials) lines for kit/bundle items — self-referential item_variant FK. Human-only edits (see D7 table — never AI-writable).
item_merge_candidate, item_merge
New this pass — mirrors crm.customer_merge_candidate/customer_merge's propose/execute split exactly, with one deliberate improvement (dedup protection) — see §9 DR-49/DR-50.
Net column delta vs. v1: v1's locked docs/old/schema/schema_modules/schema_inventory.md (2026-06-09) already specified 21 tables / 238 cols for this exact same vertical-neutral, post-pivot design — not a stale pre-pivot placeholder (unlike crm's old 8/108 baseline). This build's delta: +3 tables (21→24: stock_adjustment_request + item_merge_candidate + item_merge), +97 columns (238→335) — the incremental cost of pre-shared.plant → post-shared.plant retyping, full autonomy backfill (actor FKs, automation_source, review seams, decision_provenance), and the two new propose/execute tables.
5. Capabilities
- Catalog management — create/update/soft-delete
item/item_variantrows; enforceplant_idonly settable foritem_type='plant'; taxonomy viacategory/tag; images/barcodes as subordinate tables. - Brand catalog (minimal) —
brand(added 2026-07-20) is a bare tenant-scoped name catalog for brand/manufacturer, existing solely to give Pricing's brand-scoped price rules (pricing.price_rule.brand_id) something real to FK into.inventorydoes no brand-scoped catalog browsing, filtering, or hierarchy of its own — see thepricingmodule spec for the actual brand-scoped-pricing capability, and §9 DR-70 for why this is deliberately thin. - Variant structure —
option_type/variant_optiondrive SKU generation across pot size, color, etc. - Location-scoped stock — per-(variant, location) balances in
stock, with a maintainedlast_movement_atdead-stock/aging signal and, since 2026-07-08 (Remediation Phase 4), alast_movement_idreconciliation watermark surfaced through the newstock_reconciliation_shellview (a minimal shell today — real drift-detection math deferred, see §9 DR-67). - Movement ledger — append-only
stock_movement/stock_movement_line, sourced from POS/PO/Orders events (may_act_alone,automation_source='system') or manual entry, with optionalphoto_refcapture (deferred FK). - Reservations —
stock_reservationholds stock against pending sales/transfers. - Stock adjustments — propose-then-execute workflow via
stock_adjustment_request(review-gated, dedup-protected,estimated_impact_centsfor spend-limit enforcement) against a tenant-definedstock_adjustment_reasoncatalog. - Cycle counting —
stock_count/stock_count_linelifecycle from count start through reconciliation, with per-line counter attribution. - Lot tracking —
lot/stock_lotper-(lot, location) balances, withcondition_gradefor perishable/plant material. - Kit/BOM composition —
kit_componentbundles, human-only edits. - Dedup / merge lifecycle — propose an
item_merge_candidate(dedup-protected, review-gated), review it, and on approval execute anitem_merge(append-only audit).
No AI write path is implemented this pass — schema-only build (Drizzle + migration + tests), no InventoryService yet. See §6 and Deferred/Future Items.
6. Service Contract — InventoryService
Not built this pass — no InventoryService exists yet; schema + migration + tests only, matching the shared, multi_loc, and crm modules' own precedent (crm itself remains schema-only, and identity.agent_duty_grant shipped schema-only too — this is now the established pattern for a module's first pass). Downstream code queries inventory.* directly via Drizzle for now. An InventoryService abstraction (item/variant CRUD, stock movement posting, adjustment propose/execute orchestration, cycle-count reconciliation, merge workflow orchestration) is deferred to whenever it's built.
7. Part D — AI Capability Discovery Walk
This module received both halves of the AI Capability Plane pass in the same build: the schema-translation half (agent-as-actor FKs, automation_source, review seams, decision_provenance — applied per-table above) and the capability-discovery half (AI_CAPABILITY_PLANE.md's Part D walk), run against the proposed inventory design before the schema was finalized.
D1 — Capture targets. New item/variant creation, barcode/image capture, stock movement recording (POS sale, PO receipt, manual count correction), lot capture at intake.
D2 — Routing. No smart status placement beyond what's explicit; ruled out as no different from crm's own D2 ruling.
D3 — Maintenance. Central to inventory's autonomy story: stock-level staleness (last_movement_at dead-stock signal), item/plant taxonomy drift, dedup candidate staleness.
D4 — Error-prevention. Adjustment-request dedup (two proposals racing for the same variant/location), merge conflicts (mirrored-pair proposals), over-reservation beyond on-hand stock.
D5 — Negative-space. An item with no variants yet, a variant with zero stock everywhere, a plant-type item with no plant_id match in shared.plant's 114-row seed.
D6 — Decision-support. Reorder suggestions, dead/aging-stock markdown flags (surfaced to Pricing, never executed by inventory), item/plant taxonomy link suggestions.
D7 — Autonomy boundary (the load-bearing question). See the full per-action table below.
D8 — Evidence sources. OCR-extracted receiving costs/quantities, imported supplier catalogs, photo-based condition grading.
D9 — Reconciliation pairs. stock balance ↔ physical stock_count; avg_cost_cents ↔ actual receiving cost; item.plant_id ↔ shared.plant taxonomy match confidence.
D11 — Adversarial. Inflated adjustment requests to mask shrinkage, fake merge proposals to launder mis-costed items together, gamed cycle counts.
D14 — Lifecycle. Lot expiry/condition degradation, stock adjustment request pending→approved/rejected, cycle count draft→reconciled, merge candidate pending→approved/rejected.
D15 — Capture modality. Staff form/scanner primary; photo capture for receiving cost (OCR) and lot condition grading; partially addressed at the design-question level (see AI_CAPABILITY_GAPS.md G10 ruling, §9).
Ruled out, honestly: D10 (covered by D7/merge-reversal, same open question as crm), D12 (offline — POS-side capture is existing POS behavior, not new to inventory), D13 (channel sync — inventory is not itself e-commerce-catalog-facing; that's a future Orders/e-commerce concern).
D7 — Full per-action autonomy-boundary table
| Action | Authority |
|---|---|
| Draft a reorder suggestion | draft_only |
| Propose a stock adjustment | draft_only |
| Execute a stock adjustment | needs_approval, always, never may-act-alone |
| Flag dead/aging stock for markdown | draft_only (surfacing only — Pricing owns the write) |
| Suggest an item/plant taxonomy link | draft_only |
| Record a movement from POS/PO/Orders event | may_act_alone, automation_source='system' |
| Cycle-count reconciliation (review→reconciled) | needs_approval, always (app-enforced state machine) |
| Kit BOM edits | never (human-only) |
| Propose an item merge | draft_only |
| Execute an item merge | needs_approval, always, never may-act-alone |
| OCR-extracted receiving cost | draft_only equivalent via is_verified=false |
8. Agent Authority Mapping
inventory introduces no new authority mechanism — it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22), the same canonical pattern crm consumed first. For each autonomous inventory action, the enforcing service method (not yet built — schema-only this pass) would check whether the acting agent holds an active agent_duty_grant row for the relevant identity.permission (module_code='inventory'):
| Inventory action | Permission code | authority_level | Limit dimension |
|---|---|---|---|
| Suggest a reorder | inventory:reorder:propose |
draft_only | quantity_limit |
| Propose a stock adjustment | inventory:stock_adjustment:propose |
draft_only | quantity_limit + spend_limit_cents |
| Execute a stock adjustment | inventory:stock_adjustment:execute |
needs_approval | N/A |
| Propose an item merge | inventory:item_merge:propose |
draft_only | quantity_limit |
| Execute an item merge | inventory:item_merge:execute |
needs_approval | N/A |
inventory is the first module (illustrative rows only, none seeded) where agent_duty_grant.spend_limit_cents is expected to actually matter in practice — stock_adjustment_request.estimated_impact_cents gives it something concrete to bound against. This is a meaningful contrast with crm, where spend_limit_cents was "essentially always NULL" since crm's autonomous actions are purely volume-bounded. A stock adjustment directly changes balance-sheet valuation even with no external payment involved, so inventory is the first real test of the money-bounded half of agent_duty_grant.
9. Design Rationale (DR-48 through DR-70)
- DR-48 —
stock_adjustment_requestdedup is enforced with one partial unique index, not the two-split design that would let a located and an unlocated pending proposal coexist for the same variant:stock_adjustment_request_one_pending_unique UNIQUE, btree (tenant_id, site_id, variant_id) WHERE review_status = 'pending'. Live-tested: a second pending proposal for the same(tenant, site, variant)— even with a differentinventory_location_id(NULLvs. a real location) — is REJECTED withduplicate key value violates unique constraint "stock_adjustment_request_one_pending_unique". A two-index split would have let an agent evade the dedup gate simply by varying whether it specified a location. - DR-49 —
item_merge_candidate/item_mergemirrorcrm.customer_merge_candidate/customer_merge's propose/execute split,review_statusdefault'pending', anddecision_provenanceshape exactly — a proven, already-audited pattern being reused, not invented fresh. - DR-50 —
item_merge_candidatecloses a real gapcrm's own post-build Section 4 audit found and never fixed:crm.customer_merge_candidatehas no dedup protection at all (logged toOPEN_ITEMS, still open as of this build — seedocs/database/schema_docs/crm.md's open-items list).item_merge_candidatefixes this from day 1 with two layers:chk_item_merge_candidate_pair_order CHECK (item_a_id < item_b_id)enforces canonical pair ordering, thenitem_merge_candidate_pending_pair_unique UNIQUE (tenant_id, item_a_id, item_b_id) WHERE review_status='pending'prevents duplicate/mirrored simultaneous proposals. Live-tested: inserting the canonical-order pair succeeds; inserting the MIRRORED (reversed) pair is REJECTED by the CHECK constraint itself — it never even reaches the uniqueness check, which is correct and sufficient (the CHECK is the cheaper, earlier gate). - DR-51 —
item.plant_idisuuid, nullable, FK →shared.plant.id(ON DELETE SET NULL), gated bychk_item_plant_id_item_type: plant_id IS NULL OR item_type = 'plant'. Live-tested: inserting ahard_gooditem withplant_idset is REJECTED by this CHECK. Deliberately nullable even foritem_type='plant'rows —shared.plant's 114-plant seed doesn't cover every real SKU a nursery might carry, so forcingNOT NULLwould block legitimate plant-item creation for anything outside the seed. - DR-52 — UOM columns are retyped from v1's assumed
uuidshape to the ACTUAL lockedshared.unit_of_measure.codeshape (text, notuuid):sell_uom_code,stock_uom_code,purchase_uom_code,weight_uom_code(a new upgrade from v1's free-text-onlyweight_uom), andcapacity_uom_code(oninventory_location) are alltext, FK →shared.unit_of_measure(code).sell_uom_code/stock_uom_code/currency_codeareON DELETE RESTRICT(a sellable/stockable unit disappearing must not silently orphan live inventory);purchase_uom_code/weight_uom_code/capacity_uom_codeareON DELETE SET NULL(softer, non-transactional attributes). - DR-53 —
item_variant.currency_codegets a real FK toshared.currency.iso_code(char(3),ON DELETE RESTRICT), which v1 left entirely unenforced. - DR-54 — Every actor column is retargeted to
identity.actor(notidentity.identity_user), matching the canonical agent-actor pattern all four foundation modules received in the cross-cutting autonomy-first backfill (PROJECT_DECISIONS #19):performed_by_actor_id(wasperformed_by),started_by_actor_id/reconciled_by_actor_id(werestarted_by/reconciled_by), plus everycreated_by_actor_id/updated_by_actor_id/reviewed_by_actor_id/proposed_by_actor_id/merged_by_actor_id/counted_by_actor_idintroduced fresh this pass. - DR-55 —
stock_adjustment_request.estimated_impact_cents(bigint, nullable) is new specifically to makeidentity.agent_duty_grant.spend_limit_centsenforcement meaningful for stock-adjustment proposals. Unlikecrm, wherespend_limit_centswas essentially always NULL, a stock adjustment directly changes balance-sheet valuation even when no external payment is involved — this column gives the spend-limit dimension something real to check against. - DR-56 —
stock.last_movement_at(timestamptz, nullable) is a maintained cache giving Pricing/Reporting a cheap dead-stock/aging signal. Nothing else in the schema gave downstream modules a way to answer "how long has this stock sat untouched" without scanning the fullstock_movementledger —inventory.stock_changedfires on every movement, not on aging thresholds, so this column fills a real gap. - DR-57 —
stock_movement_line.data_source/is_verifieduse the ESTABLISHED vocabulary('seed','ai_generated','manual'), identical toshared.plant's own CHECK (verified live viapg_constraint— matches exactly). An earlier design draft had invented a'ocr'value; this was corrected before the migration was written to keep the vocabulary consistent across schemas rather than fork a module-local variant. - DR-58 —
stock_lot.condition_grade(text, CHECKA/B/C/cull) closes a pre-pivot v1 feature spec item that was never actually built:module_specs/01_inventory.mdGroup 15.3, "Plant condition grading." - DR-59 —
stock_count_line.counted_by_actor_idis new this pass, giving per-line counter attribution that v1's schema didn't capture at that granularity (only count-level attribution existed before). - DR-60 —
decision_provenanceJSONB (present on 8 tables —item,item_variant,item_image,stock,stock_adjustment_request,stock_count,item_merge_candidate,item_merge; independent verification caught thatitem_imageanditem_mergewere omitted from an earlier 6-table list, corrected here) documents two new keys per theAI_CAPABILITY_GAPS.mdgap-rulings, with no new column or table for either:memory_refs(G5 — agent memory) anddelegated_by_actor_id(G3 — multi-agent handoff). Both are documentation-only conventions on an existing JSONB column, the same "document a key, don't add a column" discipline used elsewhere in this schema. - DR-62 —
item_merge(likecrm.customer_merge) records the merge FACT but does not itself re-point every row that referenced the source item's variant tree (item_variant,stock,stock_movement_line,lot,barcode,option_type,item_category,item_tag,item_image,kit_component) to the target item — found by independent adversarial verification, a larger-blast-radius analogue of the same gap already logged againstcrm.customer_merge. This is a service-layer data-migration concern (re-pointing an entire variant subtree is not something a CHECK/trigger should silently do), not a schema defect — logged toOPEN_ITEMS.mdwith an explicit trigger: "whenInventoryService.executeMerge()is built, it must re-point every table listed above from the source item's variants to the target item's, or document why not." - DR-61 —
stock_movement.photo_refis a genuine deferred forward-ref: plain nullableuuid, confirmed live to carry no FK constraint. Thefilesschema does not exist in v2 (verified live viapsql \dn: onlyidentity,multi_loc,platform,shared,crm,inventoryexist as of this build, plus system schemas). Same treatment ascrm.customer.consumer_id's deferred-bridge pattern. Logged toOPEN_ITEMS.md, trigger: "when thefilesmodule is built." - DR-63 (added 2026-07-07, inventory reopen) —
stock_reservation.created_by_actor_id(nullable uuid, FK →identity.actor(id)) added. Found by the 2026-07-07 crm/inventory/pricing erosion audit:stock_reservationis writable by an autonomous agent (automation_source='agent') but had no actor-attribution column at all — the table's "no review seam, fully deterministic" framing (see thestock_reservationtable note above) doesn't hold for the agent-initiated write path; a misreserved order (wrong variant/quantity/site) left no trail of which agent, why, or under what confidence.source_type/source_idremain the primary trace for the deterministic system/POS-initiated case; this column adds the missing trail specifically for the agent-initiated case. Nullable, no default — 0 existing rows, zero impact on the deterministic write path. Independently confirmed as a REAL, enforced FK (a random non-existent actor id is rejected with a foreign-key violation, not silently accepted) and that the migration is additive-only (+1 column, +1 FK, 0 changes to the table's original 3 CHECKs/4 FKs/5 indexes). See PROJECT_DECISIONS #28. - DR-64 (added 2026-07-08, Remediation Phase 1) — cross-cutting senior-architect review pass closed a fail-open reviewer gap on 6 tables (
item,item_image,item_merge_candidate,item_variant,stock,stock_adjustment_request): each gained achk_*_approved_requires_reviewerCHECK (review_status <> 'approved' OR reviewed_by_actor_id IS NOT NULL), soreview_status='approved'can no longer be set with no reviewer recorded.stock_count— whose review seam isstatus-based, notreview_status-based — got the equivalent fix via a new nullablereconciled_at(timestamptz) column pluschk_stock_count_reconciled_requires_actor(status <> 'reconciled' OR reconciled_by_actor_id IS NOT NULL).stock_count.reconciled_atis the only column added (336→337 cols; 24 tables unchanged) — every other change is CHECK-only. See PROJECT_DECISIONS #37. - DR-65 (added 2026-07-08, Remediation Phase 2) — cross-cutting PK-generation-strategy pass (Item 6) changed the
idcolumnDEFAULTonitem_merge,stock_movement,stock_movement_linefromgen_random_uuid()(UUIDv4) toplatform.uuid_generate_v7()(UUIDv7);stock_movement/stock_movement_lineare 2 of the plan's 4 named "hot ledgers." UUIDv7 is time-ordered, keeping future time-range partitioning possible on these append-only ledgers without a PK rewrite — impossible once data lands on a random UUIDv4 PK.item_category/item_tagwere considered and explicitly EXCLUDED — both are commented "hard-delete" join tables in their own Drizzle source, not append-only. DEFAULT-only change: 337 columns unchanged, 24 tables unchanged. See PROJECT_DECISIONS #38. - DR-66 (added 2026-07-08, Remediation Phase 3) —
stock_movement.movement_type's CHECK (chk_stock_movement_movement_type) widened to add'produced': wasIN ('received','sold','transferred','adjusted','counted','returned'), now also includes'produced'.source_modulehas permitted'production'since this table's original 2026-07-06 build, but no realmovement_typeexisted to pair with it, so a nursery propagating its own stock (cuttings/seed → saleable plant) had no real path to represent this event at all — this had been an open, re-logged deferral since 2026-07-07 (see §10). Pure enum-widen: zero column change, a strict superset of the prior CHECK for every existing row — confirmed live that 0 rows hadmovement_type='produced'before the widen, so no prior row is rejected by it. Built in the same migration as apos-side tender-gate CHECK rename (packages/db/migrations/20260708240000_phase3_item11_reward_tender_and_produced_stock.sql); seepos's own module spec for that half. 337 columns unchanged, 24 tables unchanged. See PROJECT_DECISIONS #39. - DR-67 (added 2026-07-08, Remediation Phase 4, Item 20b) —
stock.last_movement_id(nullable UUID, FK →stock_movement.id) added as a reconciliation watermark: 0 rows at build time, zero backfill risk. Alongside it,inventory.stock_reconciliation_shell— the firstCREATE VIEWin this entire codebase (a codebase-wide grep confirmed no priorCREATE VIEWprecedent exists anywhere) — was created as a plainLEFT JOIN(stockLEFT JOINstock_movementonlast_movement_id) exposingstock_id,tenant_id,site_id,variant_id,on_hand_qty,last_movement_id,last_movement_at,last_movement_recorded_at, with zero aggregation math (noSUM/GROUP BY) — independently confirmed viapg_get_viewdefby both verification lenses. This was a deliberate pre-build scoping decision: the REAL reconciliation logic (sign-aware sums ofstock_movement_linedeltas sincelast_movement_id, compared againststock.on_hand_qty, matchingmovement_type's existing sign vocabulary — received/produced/returned positive, sold/transferred negative, adjusted/counted either) is explicitly flagged UNVERIFIED against real transfer data and deferred as a named follow-up, logged to OPEN_ITEMS, not silently assumed correct. The view does NOT count toward the module's table total —information_schema.tablesincludes VIEWs by default, soinventory-schema.spec.ts's hardcoded table-count assertion needed atable_type = 'BASE TABLE'filter added to stay correct at 24. 337→338 columns (+1,stock.last_movement_idonly), 24 tables unchanged, +1 view. Migration:packages/db/migrations/20260709060000_phase4_item20_outbox_and_stock_reconciliation.sql(part b) — part a of the same file buildsplatform.outbox; seeplatform's own module spec for that half. See PROJECT_DECISIONS #40. - DR-68 (added 2026-07-10, Header/Line Remediation reopen, fixes #5 and #9) — Fix #5:
stock_adjustment_batch(9 metadata cols +id/tenant_id= 10 total) is a new header table grouping multiplestock_adjustment_requestrows for joint review (e.g. a full-shelf recount touching 20 variants). Reusesstock_count's own "status as review seam" convention verbatim (open/reviewing/approved/rejected), plus apartially_approvedvalue since lines can resolve independently — itself reusingorder_header.status's ownpartially_fulfilledprecedent. Carries its ownUNIQUE(id, tenant_id)(stock_adjustment_batch_id_tenant_id_unique) from creation — the prerequisite forstock_adjustment_request.batch_id's new composite FK (stock_adjustment_request_batch_tenant_fkey), added on this brand-new table's own DDL from the start rather than as a later correction (this whole remediation effort's own established rule for every new parent a composite FK will target). Fully additive:stock_adjustment_requesthad 0 live rows at build time, sobatch_id(nullable, no default) requires zero backfill. Live-tested: a cross-tenantbatch_idreference is REJECTED by the composite FK (23503). Fix #9:stock_count_linegainsreconciled_at(nullable timestamptz) +reconciled_by_actor_id(nullable FK →identity.actor) + a NULL-safe CHECK (chk_stock_count_line_reconciled_requires_counted: reconciled_at IS NULL OR counted_qty IS NOT NULL) + a bespoke conditional-immutability trigger (trg_stock_count_line_lock_after_reconciled, functioninventory.reject_stock_count_line_mutation_after_reconciled()) — deliberately NOT the shared blanketplatform.reject_append_only_mutation(), since a count line must stay legitimately editable (filling incounted_qty, correcting a mis-entry) up until the moment it's reconciled. The trigger'sRETURN COALESCE(NEW, OLD)correctly handles bothUPDATE(returnsNEW) andDELETE(falls through toOLD, avoiding the "BEFORE DELETEreturning NULL silently cancels" footgun).stock_count_linehad 0 live rows at build time — zero backfill needed. Live-tested: a pre-reconciliationcounted_qtyUPDATE succeeds; oncereconciled_atis set, both UPDATE and DELETE are rejected with the exact claimed error message. 2 disclosed gaps found while documenting this reopen (not part of the original fix scope, not silently fixed): (1)stock_movement_linestill lacksUNIQUE(id, tenant_id), despite the design doc naming this as belonging to this same reopen (it's the prerequisite for Purchasing's own still-deferred fix #7, movement-line linkage) — confirmed live, zero unique constraints exist on that table (closed later the same day by a companion reopen — the Receiving extraction itself — see §9 DR-69); (2)stock_adjustment_batch.updated_athas no maintaining trigger — confirmed live viapg_trigger, zero triggers exist on this table at all, unlike every sibling soft-delete table in this module. Both logged toOPEN_ITEMS.md. +1 table, +13 columns (10 new table +stock_adjustment_request.batch_id+stock_count_line's 2 new columns): 338→351 columns, 24→25 tables — verified live viainformation_schema.columns/.tables(with thetable_type='BASE TABLE'filter). Migration:packages/db/migrations/20260710030000_headerline_inventory_fixes.sql. Tests:inventory-schema.spec.tsnew sections O (2 tests) and P (5 tests), file now 55 tests (up from 47), all passing. See PROJECT_DECISIONS #49. - DR-69 (added 2026-07-10, same-day companion reopen — the Receiving extraction) — a second, distinct
inventoryreopen on 2026-07-10, immediately following the Header/Line Remediation reopen (DR-68) above. This one is required, not optional:purchasing.purchase_receipt/purchase_receipt_linewere extracted the same day into a brand-newreceivingschema (receiving.goods_receipt/receiving.goods_receipt_line— tracked inreceiving's own module spec, not here), andgoods_receipt_line's own composite FKs —variant_id → item_variant,lot_id → lot,stock_movement_id → stock_movement(renamed frominventory_movement_id, upgraded from bare), and the newstock_movement_line_id → stock_movement_line(closing Purchasing's long-deferred fix #7, movement-line linkage) — each need a matchingUNIQUE(id, tenant_id)on theinventory-side parent for Postgres to accept a composite FK at all, the same prerequisite pattern every other composite-FK addition in this codebase has followed since Remediation Phase 1. Four tables gainedUNIQUE(id, tenant_id)this reopen:item_variant,lot,stock_movement,stock_movement_line. The last of these closes a gap this same file had just disclosed as open in DR-68 ("stock_movement_linestill lacksUNIQUE(id, tenant_id)... it's the prerequisite for Purchasing's own still-deferred fix #7... deferred to the upcoming Receiving extraction") — that Receiving extraction happened later the same day, so the gap is CLOSED, not re-deferred a second time (see §10). Pure constraint-shape addition on theinventoryside: zero columns, zero tables, zero triggers touched —receivingis the schema that gained the 2 tables (62 cols total), notinventory.inventoryitself stays 25 tables / 351 columns (fully unchanged by this reopen). Live-tested as part of the Receiving-extraction build's own verification pass (a 2-tenant parallel fixture, run inside one transaction, rolled back — zero residue): each of these 4 new unique constraints backs a real composite FK fromreceiving.goods_receipt_linethat correctly REJECTS a cross-tenant reference, independently confirmed per column (not just "at least one FK on the row rejects"). Migration:packages/db/migrations/20260710090000_receiving_extraction.sql(the same migration also moves the 2 tables out ofpurchasingand retargetspurchasing.vendor_invoice_match/vendor_return_linetoreceiving.goods_receipt_line— seepurchasing's andreceiving's own module specs for those halves). Design doc:~/Downloads/vrida-cc-task-2026-07-10-orphan-fix-and-receiving-design.md, Part B (§B.1–B.18). - DR-70 (added 2026-07-20, Gap-Fill Batch — Pricing's A1 category/brand-scoped price rules reopen, PROJECT_DECISIONS #74) — this reopen was a SIDE EFFECT of
pricing's own reopen, not a named gap forinventoryitself. Two pieces landed: (1)categorygainedUNIQUE(id, tenant_id)(category_id_tenant_id_unique) — a pure constraint-shape prerequisite forpricing.price_rule.category_id's new composite FK (price_rule_category_id_tenant_fkey), zero column change. (2) The newbrandtable (id/tenant_id/name/created_by_actor_id/created_at/updated_at/deleted_at, 7 cols) is deliberately MINIMAL — an explicit architect ruling (AskUserQuestion), made live during this reopen after a codebase-wide grep confirmed no brand catalog table existed anywhere, chose to build a bare name catalog now rather than defer brand-scoped pricing entirely: noslug,description, or hierarchy, since nothing beyondpricing.price_rule.brand_idconsumes it yet.brand_tenant_name_unique UNIQUE(tenant_id, name)blocks duplicate brand names per tenant;brand_id_tenant_id_unique UNIQUE(id, tenant_id)is the prerequisite forpricing.price_rule.brand_id's own composite FK (price_rule_brand_id_tenant_fkey). RLS enabled with the standard tenant-isolation policy every other tenant-scoped table in this schema uses;set_updated_attrigger attached from creation (unlikestock_adjustment_batch's own disclosed gap, this table's migration wires the trigger correctly). If brand management becomes a real product requirement (a merchandiser-facing brand directory, storefront brand pages, brand-level analytics, etc.) this table would need at minimum aslug(URL-safe identifier, matchingcategory.slug's own partial-unique pattern), adescription, alogo_ref/logo_url(onceFilesexists), and possibly a parent/hierarchy column if sub-brands under a parent manufacturer become a requirement — none of that is anticipated or reserved for by this schema today; it is a plain flat name list, nothing more. 6 new regression tests added toinventory-schema.spec.ts(brand insert defaults, duplicate-name-same-tenant rejection, same-name-different-tenant success, RLS tenant isolation, pluscategory's new constraint check), all passing. Seedocs/decisions/PROJECT_DECISIONS.md#74 andpackages/db/migrations/20260720000001_inventory_reopen_category_unique_brand_catalog.sql.
AI_CAPABILITY_GAPS.md — gap-ruling table (G1–G11)
| Gap | Ruling |
|---|---|
| G1 (agent-readable catalog flag) | Ruled OUT for schema this pass, per the gaps doc's own closing scope note ("agentic commerce (G1)... not the schema-design runbook"). The existing OPEN_ITEMS.md row 104 (the original G1 row) is UPDATED, not duplicated, to note this ruling and re-point its trigger to "when the API layer builds agentic-commerce exposure." |
| G2 | None — genuinely out of scope (API-layer concern). |
| G3 (multi-agent handoff) | Considered, correctly deferred; recorded via decision_provenance.delegated_by_actor_id, no new table. |
| G4 (semantic layer) | Cross-cutting, not inventory-specific; out of scope. |
| G5 (agent memory) | Built as documentation: decision_provenance.memory_refs key. |
| G6 | None — genuinely out of scope (testing concern). |
| G7 | None — genuinely out of scope (runtime concern). |
| G8 (proactive/scheduled agents) | Cross-cutting shared infrastructure, not inventory's to build alone; out of scope. |
| G9 | None — genuinely out of scope (future-platform concern). |
| G10 (voice/vision capture) | Partially addressed by Part D's D15 (capture modality) at the design-question level. |
| G11 | None — genuinely out of scope (future-platform concern). |
10. Deferred / Future Items
All items tracked in docs/open-items/OPEN_ITEMS.md, attributed to inventory (PROJECT_DECISIONS #24). Summary for context:
| Item | Status | Trigger |
|---|---|---|
stock_movement.photo_ref forward-ref |
deferred | The files module doesn't exist in v2 (verified live). Trigger: when the files module is built. |
InventoryService (all service-layer methods) |
open | No InventoryService exists yet — schema-only this pass, same pattern as agent_duty_grant's and crm's deferred service layers. Trigger: when InventoryService is built. |
| pg_trgm fuzzy-search GIN indexes | deferred | v1's gin_trgm_ops on name/sku deferred — the pg_trgm Postgres extension is not yet enabled in this database (verified live via pg_extension). Trigger: when pg_trgm is enabled / the Search module properly builds fuzzy search. |
item.attributes/item_variant.attributes JSONB per-item_type example shapes |
deferred | Carried forward from v1's own deferred note, narrowed in scope now that shared.plant owns taxonomy/care facts. Trigger: when InventoryService is built. |
stock_adjustment_request vs. agent_duty_grant.spend_limit_cents cumulative tracking |
open (cross-reference) | Same documented security limitation as agent_duty_grant itself (per-action only, no cumulative/period tracking) applies here too — inventory is the first module to actually rely on this limit in practice. No new row duplicated; cross-referenced against identity's existing OPEN_ITEMS row. |
| G1 row (agent-readable catalog flag) | updated, not duplicated | Re-pointed trigger: "when the API layer builds agentic-commerce exposure." |
stock_movement.movement_type missing 'produced' |
closed 2026-07-08 | Resolved by Remediation Phase 3 (Item 11) — see §9 DR-66. chk_stock_movement_movement_type now includes 'produced', pairing with source_module='production'. |
stock_reconciliation_shell's real drift-detection logic |
open, new 2026-07-08 (Remediation Phase 4, Item 20b) | The shipped view is a deliberately minimal shell (plain LEFT JOIN, zero aggregation). The real logic — sign-aware sums of stock_movement_line deltas since last_movement_id, compared against stock.on_hand_qty — is UNVERIFIED against real transfer data. Trigger: when real transfer data exists to verify the sign-aware sum logic against. |
Scheduled job: expire stale stock_reservation rows |
open, new 2026-07-08 (Remediation Phase 4, Item 20c) | Documented, not DB-enforced (no pg_cron in this environment) — any active reservation with expires_at < now() should transition to expired via a periodic job. Schema has supported this since the 2026-07-06 original build; only the job itself is unbuilt. Trigger: when a scheduled-job runner exists in this codebase. |
stock_movement_line still missing UNIQUE(id, tenant_id) |
closed 2026-07-10 (same-day companion reopen) | Disclosed as open earlier the same day by the Header/Line Remediation reopen (constraint named in that reopen's own design doc but not added in that pass). Closed hours later by a required companion reopen — the Receiving extraction itself, which needed exactly this constraint as a composite-FK prerequisite for receiving.goods_receipt_line.stock_movement_line_id (closing Purchasing's own long-deferred fix #7, movement-line linkage, in the same stroke). See §9 DR-69. |
stock_adjustment_batch.updated_at not trigger-maintained |
open, new 2026-07-10 (Header/Line Remediation reopen, disclosure) | Confirmed live via pg_trigger — zero triggers exist on this table, unlike every sibling soft-delete table in this module (all use platform.set_updated_at()). Found while documenting this reopen, not previously logged. Trigger: next time stock_adjustment_batch is touched. |
11. Cross-Module Seams
Seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here). Key relationships:
- inventory → shared:
item.plant_id → shared.plant.id(nullable FK,ON DELETE SET NULL) — the first realshared.plantconsumer.item_variant.sell_uom_code/stock_uom_code/purchase_uom_code/weight_uom_code+inventory_location.capacity_uom_code→shared.unit_of_measure.code.item_variant.currency_code → shared.currency.iso_code. - inventory → multi_loc:
inventory_location.site_id,stock.site_id,stock_movement.site_id,stock_reservation.site_id,stock_count.site_id,stock_lot.site_id,stock_adjustment_request.site_id— all →multi_loc.site.id, all enforced. Unlikecrm's narrow site_id exception on interaction tables, site-scoping is core to howinventoryworks across every stock-bearing table. - inventory → identity: every
*_actor_idcolumn →identity.actor.id(enforced) — the same canonical autonomy patterncrmconsumed first, nowinventory's turn. - inventory → agent_duty_grant: pure consumer, no new mechanism (see §8 mapping table).
- receiving → inventory (NEW, 2026-07-10):
receiving.goods_receipt_line— composite FKs intoitem_variant,lot,stock_movement, andstock_movement_line(this last one closing Purchasing's own long-deferred fix #7).inventory's append-onlystock_movement/stock_movement_linerows are never edited or deleted byreceiving's reversal flow — a reversal is a compensating line with a negativequantity_delta, not a mutation. The prerequisiteUNIQUE(id, tenant_id)on all fourinventorytables is this same-day companion reopen's own addition — see §9 DR-69. - inventory → Files (DEFERRED):
stock_movement.photo_ref— plainuuid, no FK,filesschema absent from v2 (verified live). - inventory → Pricing (flagged, not built):
inventoryownsitem_variant.base_price_cents(list anchor) andavg_cost_cents(weighted-average costing); Pricing (not built) resolves actual sale price and owns markdown execution entirely. No live FK seam yet. - pricing → inventory (NEW, 2026-07-20):
pricing.price_rule.category_id/.brand_id— composite FKs (price_rule_category_id_tenant_fkey/price_rule_brand_id_tenant_fkey) intoinventory.category/inventory.brand, added as a side effect of Pricing's own Gap-Fill Batch A1 reopen (category/brand-scoped price rules).categorygained the prerequisiteUNIQUE(id, tenant_id)(category_id_tenant_id_unique);brand(new, minimal catalog table) carries its ownUNIQUE(id, tenant_id)from creation. See §9 DR-70 — the brand-scoped-pricing capability itself is documented inpricing's own module spec, not here. - inventory → Purchasing (deferred, not built):
InventoryService.receiveStock(source_module='purchasing')already anticipated instock_movement.source_module's CHECK; no live seam row yet since Purchasing isn't built.
Inventory Core Write Protection — built schema boundary (2026-07-15 task run)
Inventory operational writes are now database-mediated. A private complete-movement primitive, protected reservation create/transition routines, and combined reservation-fulfillment posting enforce stock/reservation/ledger atomicity. inventory_command_executor currently has zero function grants; the generic UUID-only transition routine remains private until a source-specific authority wrapper exists. Ordinary application roles receive no executor membership. Returns has the one authorized source-specific wrapper in this build, invoked only by the persisted-row AFTER INSERT trigger.
The Inventory Core recertification baseline was 26 tables / 362 columns, 62 CHECKs, 118 FKs, 26 PKs, 13 UNIQUE constraints, 137 indexes, 38 policies, and 31 non-internal triggers. Stock Transfer builds on that boundary to produce the current 29/425/1 shape stated at the top of this file. Numeric, reservation-to-stock, tenant-FK, role-closure, function-owner/search-path, default-ACL, movement-cardinality, and Transfer-source isolation sweeps are clean.
No Transfer objects were built during the Inventory Core reopen itself. The subsequent authorized Transfer migration created the tables first and the narrow wrappers in the same migration set, with no runtime EXECUTE grant. Those wrappers reuse the private Inventory primitives and prove Transfer attribution remains impossible through every non-Transfer wrapper.
The first independent post-build verifier returned NOT CLEAN. Additive migrations 20260720000016–00020 fixed its fulfillment-order blocker, converted the Returns posting trigger to post-RLS/FK AFTER INSERT, reran lock-protected migration preflight/source sweeps, closed the executor and every remaining inherited Inventory PUBLIC-function grant, removed temporary migration-role membership, and made reservation fulfillment source-unique so a changed retry key cannot post twice. The first report remains verbatim in PROJECT_DECISIONS #75; the separate final fresh-context verifier returned CLEAN and the module is re-locked at this schema boundary.