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-only transfer_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_cents and the same lot_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.plantitem.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). inventory owns item_variant.base_price_cents (list anchor) and avg_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_module already anticipates a future receiveStock(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, the files schema does not exist). inventory holds only one deferred bridge column, stock_movement.photo_ref (plain nullable uuid, no FK).
  • POS/e-commerce sale capture → POS/Orders (not yet built in v2). inventory records the resulting stock_movement via may_act_alone system automation; it does not own the sale transaction itself.

3. Layer & Dependencies

Tenant-scoped operational layer, second product-tier module.

Depends on:

  • platformplatform.tenant is the FK target for every tenant-scoped inventory table; platform.set_updated_at() trigger (on the 19 tables that carry updated_at).
  • shared (module #3) — item.plant_id → shared.plant.id (nullable FK, ON DELETE SET NULL) is inventory's headline new seam and the first live consumer of shared.plant; also shared.unit_of_measure.code for sell_uom_code/stock_uom_code/purchase_uom_code/weight_uom_code (on item_variant) and capacity_uom_code (on inventory_location); shared.currency.iso_code for item_variant.currency_code.
  • multi_loc (module #4) — multi_loc.site FK on 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 enforced, not a narrow exception like crm's; site-scoping is core to how inventory works, 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 to identity.actor, not identity.identity_user. Prospectively depends on identity.agent_duty_grant for 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_size4in/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_variant rows; enforce plant_id only settable for item_type='plant'; taxonomy via category/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. inventory does no brand-scoped catalog browsing, filtering, or hierarchy of its own — see the pricing module spec for the actual brand-scoped-pricing capability, and §9 DR-70 for why this is deliberately thin.
  • Variant structureoption_type/variant_option drive SKU generation across pot size, color, etc.
  • Location-scoped stock — per-(variant, location) balances in stock, with a maintained last_movement_at dead-stock/aging signal and, since 2026-07-08 (Remediation Phase 4), a last_movement_id reconciliation watermark surfaced through the new stock_reconciliation_shell view (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 optional photo_ref capture (deferred FK).
  • Reservationsstock_reservation holds stock against pending sales/transfers.
  • Stock adjustments — propose-then-execute workflow via stock_adjustment_request (review-gated, dedup-protected, estimated_impact_cents for spend-limit enforcement) against a tenant-defined stock_adjustment_reason catalog.
  • Cycle countingstock_count/stock_count_line lifecycle from count start through reconciliation, with per-line counter attribution.
  • Lot trackinglot/stock_lot per-(lot, location) balances, with condition_grade for perishable/plant material.
  • Kit/BOM compositionkit_component bundles, human-only edits.
  • Dedup / merge lifecycle — propose an item_merge_candidate (dedup-protected, review-gated), review it, and on approval execute an item_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_idshared.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-48stock_adjustment_request dedup 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 different inventory_location_id (NULL vs. a real location) — is REJECTED with duplicate 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-49item_merge_candidate/item_merge mirror crm.customer_merge_candidate/customer_merge's propose/execute split, review_status default 'pending', and decision_provenance shape exactly — a proven, already-audited pattern being reused, not invented fresh.
  • DR-50item_merge_candidate closes a real gap crm's own post-build Section 4 audit found and never fixed: crm.customer_merge_candidate has no dedup protection at all (logged to OPEN_ITEMS, still open as of this build — see docs/database/schema_docs/crm.md's open-items list). item_merge_candidate fixes this from day 1 with two layers: chk_item_merge_candidate_pair_order CHECK (item_a_id < item_b_id) enforces canonical pair ordering, then item_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-51item.plant_id is uuid, nullable, FK → shared.plant.id (ON DELETE SET NULL), gated by chk_item_plant_id_item_type: plant_id IS NULL OR item_type = 'plant'. Live-tested: inserting a hard_good item with plant_id set is REJECTED by this CHECK. Deliberately nullable even for item_type='plant' rows — shared.plant's 114-plant seed doesn't cover every real SKU a nursery might carry, so forcing NOT NULL would block legitimate plant-item creation for anything outside the seed.
  • DR-52 — UOM columns are retyped from v1's assumed uuid shape to the ACTUAL locked shared.unit_of_measure.code shape (text, not uuid): sell_uom_code, stock_uom_code, purchase_uom_code, weight_uom_code (a new upgrade from v1's free-text-only weight_uom), and capacity_uom_code (on inventory_location) are all text, FK → shared.unit_of_measure(code). sell_uom_code/stock_uom_code/currency_code are ON DELETE RESTRICT (a sellable/stockable unit disappearing must not silently orphan live inventory); purchase_uom_code/weight_uom_code/capacity_uom_code are ON DELETE SET NULL (softer, non-transactional attributes).
  • DR-53item_variant.currency_code gets a real FK to shared.currency.iso_code (char(3), ON DELETE RESTRICT), which v1 left entirely unenforced.
  • DR-54 — Every actor column is retargeted to identity.actor (not identity.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 (was performed_by), started_by_actor_id/reconciled_by_actor_id (were started_by/reconciled_by), plus every created_by_actor_id/updated_by_actor_id/reviewed_by_actor_id/proposed_by_actor_id/merged_by_actor_id/counted_by_actor_id introduced fresh this pass.
  • DR-55stock_adjustment_request.estimated_impact_cents (bigint, nullable) is new specifically to make identity.agent_duty_grant.spend_limit_cents enforcement meaningful for stock-adjustment proposals. Unlike crm, where spend_limit_cents was 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-56stock.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 full stock_movement ledger — inventory.stock_changed fires on every movement, not on aging thresholds, so this column fills a real gap.
  • DR-57stock_movement_line.data_source/is_verified use the ESTABLISHED vocabulary ('seed','ai_generated','manual'), identical to shared.plant's own CHECK (verified live via pg_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-58stock_lot.condition_grade (text, CHECK A/B/C/cull) closes a pre-pivot v1 feature spec item that was never actually built: module_specs/01_inventory.md Group 15.3, "Plant condition grading."
  • DR-59stock_count_line.counted_by_actor_id is 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-60decision_provenance JSONB (present on 8 tables — item, item_variant, item_image, stock, stock_adjustment_request, stock_count, item_merge_candidate, item_merge; independent verification caught that item_image and item_merge were omitted from an earlier 6-table list, corrected here) documents two new keys per the AI_CAPABILITY_GAPS.md gap-rulings, with no new column or table for either: memory_refs (G5 — agent memory) and delegated_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-62item_merge (like crm.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 against crm.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 to OPEN_ITEMS.md with an explicit trigger: "when InventoryService.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-61stock_movement.photo_ref is a genuine deferred forward-ref: plain nullable uuid, confirmed live to carry no FK constraint. The files schema does not exist in v2 (verified live via psql \dn: only identity, multi_loc, platform, shared, crm, inventory exist as of this build, plus system schemas). Same treatment as crm.customer.consumer_id's deferred-bridge pattern. Logged to OPEN_ITEMS.md, trigger: "when the files module 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_reservation is 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 the stock_reservation table 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_id remain 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 a chk_*_approved_requires_reviewer CHECK (review_status <> 'approved' OR reviewed_by_actor_id IS NOT NULL), so review_status='approved' can no longer be set with no reviewer recorded. stock_count — whose review seam is status-based, not review_status-based — got the equivalent fix via a new nullable reconciled_at (timestamptz) column plus chk_stock_count_reconciled_requires_actor (status <> 'reconciled' OR reconciled_by_actor_id IS NOT NULL). stock_count.reconciled_at is 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 id column DEFAULT on item_merge, stock_movement, stock_movement_line from gen_random_uuid() (UUIDv4) to platform.uuid_generate_v7() (UUIDv7); stock_movement/stock_movement_line are 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_tag were 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': was IN ('received','sold','transferred','adjusted','counted','returned'), now also includes 'produced'. source_module has permitted 'production' since this table's original 2026-07-06 build, but no real movement_type existed 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 had movement_type='produced' before the widen, so no prior row is rejected by it. Built in the same migration as a pos-side tender-gate CHECK rename (packages/db/migrations/20260708240000_phase3_item11_reward_tender_and_produced_stock.sql); see pos'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_shellthe first CREATE VIEW in this entire codebase (a codebase-wide grep confirmed no prior CREATE VIEW precedent exists anywhere) — was created as a plain LEFT JOIN (stock LEFT JOIN stock_movement on last_movement_id) exposing stock_id, tenant_id, site_id, variant_id, on_hand_qty, last_movement_id, last_movement_at, last_movement_recorded_at, with zero aggregation math (no SUM/GROUP BY) — independently confirmed via pg_get_viewdef by both verification lenses. This was a deliberate pre-build scoping decision: the REAL reconciliation logic (sign-aware sums of stock_movement_line deltas since last_movement_id, compared against stock.on_hand_qty, matching movement_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.tables includes VIEWs by default, so inventory-schema.spec.ts's hardcoded table-count assertion needed a table_type = 'BASE TABLE' filter added to stay correct at 24. 337→338 columns (+1, stock.last_movement_id only), 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 builds platform.outbox; see platform'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 multiple stock_adjustment_request rows for joint review (e.g. a full-shelf recount touching 20 variants). Reuses stock_count's own "status as review seam" convention verbatim (open/reviewing/approved/rejected), plus a partially_approved value since lines can resolve independently — itself reusing order_header.status's own partially_fulfilled precedent. Carries its own UNIQUE(id, tenant_id) (stock_adjustment_batch_id_tenant_id_unique) from creation — the prerequisite for stock_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_request had 0 live rows at build time, so batch_id (nullable, no default) requires zero backfill. Live-tested: a cross-tenant batch_id reference is REJECTED by the composite FK (23503). Fix #9: stock_count_line gains reconciled_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, function inventory.reject_stock_count_line_mutation_after_reconciled()) — deliberately NOT the shared blanket platform.reject_append_only_mutation(), since a count line must stay legitimately editable (filling in counted_qty, correcting a mis-entry) up until the moment it's reconciled. The trigger's RETURN COALESCE(NEW, OLD) correctly handles both UPDATE (returns NEW) and DELETE (falls through to OLD, avoiding the "BEFORE DELETE returning NULL silently cancels" footgun). stock_count_line had 0 live rows at build time — zero backfill needed. Live-tested: a pre-reconciliation counted_qty UPDATE succeeds; once reconciled_at is 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_line still lacks UNIQUE(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_at has no maintaining trigger — confirmed live via pg_trigger, zero triggers exist on this table at all, unlike every sibling soft-delete table in this module. Both logged to OPEN_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 via information_schema.columns/.tables (with the table_type='BASE TABLE' filter). Migration: packages/db/migrations/20260710030000_headerline_inventory_fixes.sql. Tests: inventory-schema.spec.ts new 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 inventory reopen 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_line were extracted the same day into a brand-new receiving schema (receiving.goods_receipt/receiving.goods_receipt_line — tracked in receiving's own module spec, not here), and goods_receipt_line's own composite FKs — variant_id → item_variant, lot_id → lot, stock_movement_id → stock_movement (renamed from inventory_movement_id, upgraded from bare), and the new stock_movement_line_id → stock_movement_line (closing Purchasing's long-deferred fix #7, movement-line linkage) — each need a matching UNIQUE(id, tenant_id) on the inventory-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 gained UNIQUE(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_line still lacks UNIQUE(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 the inventory side: zero columns, zero tables, zero triggers touched — receiving is the schema that gained the 2 tables (62 cols total), not inventory. inventory itself 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 from receiving.goods_receipt_line that 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 of purchasing and retargets purchasing.vendor_invoice_match/vendor_return_line to receiving.goods_receipt_line — see purchasing's and receiving'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 for inventory itself. Two pieces landed: (1) category gained UNIQUE(id, tenant_id) (category_id_tenant_id_unique) — a pure constraint-shape prerequisite for pricing.price_rule.category_id's new composite FK (price_rule_category_id_tenant_fkey), zero column change. (2) The new brand table (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: no slug, description, or hierarchy, since nothing beyond pricing.price_rule.brand_id consumes 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 for pricing.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_at trigger attached from creation (unlike stock_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 a slug (URL-safe identifier, matching category.slug's own partial-unique pattern), a description, a logo_ref/logo_url (once Files exists), 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 to inventory-schema.spec.ts (brand insert defaults, duplicate-name-same-tenant rejection, same-name-different-tenant success, RLS tenant isolation, plus category's new constraint check), all passing. See docs/decisions/PROJECT_DECISIONS.md #74 and packages/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 real shared.plant consumer. item_variant.sell_uom_code/stock_uom_code/purchase_uom_code/weight_uom_code + inventory_location.capacity_uom_codeshared.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. Unlike crm's narrow site_id exception on interaction tables, site-scoping is core to how inventory works across every stock-bearing table.
  • inventory → identity: every *_actor_id column → identity.actor.id (enforced) — the same canonical autonomy pattern crm consumed first, now inventory'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 into item_variant, lot, stock_movement, and stock_movement_line (this last one closing Purchasing's own long-deferred fix #7). inventory's append-only stock_movement/stock_movement_line rows are never edited or deleted by receiving's reversal flow — a reversal is a compensating line with a negative quantity_delta, not a mutation. The prerequisite UNIQUE(id, tenant_id) on all four inventory tables is this same-day companion reopen's own addition — see §9 DR-69.
  • inventory → Files (DEFERRED): stock_movement.photo_ref — plain uuid, no FK, files schema absent from v2 (verified live).
  • inventory → Pricing (flagged, not built): inventory owns item_variant.base_price_cents (list anchor) and avg_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) into inventory.category/inventory.brand, added as a side effect of Pricing's own Gap-Fill Batch A1 reopen (category/brand-scoped price rules). category gained the prerequisite UNIQUE(id, tenant_id) (category_id_tenant_id_unique); brand (new, minimal catalog table) carries its own UNIQUE(id, tenant_id) from creation. See §9 DR-70 — the brand-scoped-pricing capability itself is documented in pricing's own module spec, not here.
  • inventory → Purchasing (deferred, not built): InventoryService.receiveStock(source_module='purchasing') already anticipated in stock_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 2026072000001600020 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.

Last modified: Jul 16, 2026, 9:05 AM PT
On this page
Esc