Database architecture skills, docs, and rules for high-demand multi-tenant commerce platforms (PostgreSQL source of truth, Neo4j as derived GraphRAG projection, transactional outbox, RLS-based tenant isolation). Includes live schema introspection workflow via Postgres MCP.
97
97%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
desired_sell_price on pricing_rulesA complete pass through the nine-step schema-evolution workflow.
"I need to add an attribute to product to track the price the user actually wants to sell at."
MCP introspection of catalog_products:
| Column | Type | Notes |
|---|---|---|
| id | bigint PK | identity |
| public_id | uuid UK | external id |
| tenant_id | bigint NOT NULL | tenant scope |
| sku | text | UQ (tenant_id, sku) |
| name | text | |
| brand_id | bigint FK | |
| price | numeric(12,2) | list price |
| cost | numeric(12,2) | unit cost |
| compare_at_price | numeric(12,2) | strikethrough/MSRP |
| metadata | jsonb | informal wholesale_price in 8 % of rows |
| created_at | timestamptz | |
| updated_at | timestamptz | |
| deleted_at | timestamptz | soft-delete |
Neighbour pricing_rules: tenant-scoped, holds per-channel pricing overrides.
JSONB sampling on metadata.wholesale_price: present in ≈ 8 % of rows,
inconsistent units (some cents, some dollars).
schema-change-log.md shows ADR-0009 (2026-03-12): compare_at_price is the
strikethrough/MSRP, distinct from the selling price. Settled.
catalog_products — could fit if intrinsic. Doesn't
fit: table at 13 columns, semantic overlap with price and
compare_at_price would mislead readers.catalog_products.metadata — already happening informally.
Forbidden: § V — selling price is a price-critical field.product_target_prices — doesn't fit: overkill
for a single value per product per tenant.pricing_rules) — best fit. Selling price is a
pricing-rule concern; the resolved value is the effective price exposed
via a derived view.Recommended: Option E — add pricing_rules.target_price
numeric(12,2) NULL with kind = 'target_sell'. Backfill the 8 % of rows
using metadata.wholesale_price, then drop the JSONB key.
| Concern | Detail |
|---|---|
| Incoming FKs | pricing_rules already referenced by pricing_rule_history. |
| Outgoing FKs | pricing_rules.product_id → catalog_products(id) already exists. |
| Indexes touched | Add idx_pricing_rules_tenant_product_kind (tenant_id, product_id, kind). |
| RLS policies | Existing tenant_isolation policy covers new column. No change. |
| Outbox events | Bump PriceChanged to v2 with price_type discriminator (list/compare_at/target_sell/effective). |
| Neo4j projection | Product.target_price denormalized property; refreshed on PriceChanged@v2. |
| Tenant uniqueness | Add UNIQUE (tenant_id, product_id, kind) to enforce one target per product per tenant. |
| Existing data shape | 8 % of products have metadata.wholesale_price; backfill into pricing_rules then delete the JSONB key. |
| Module ownership | pricing module (no cross-module change). |
| Compliance | N/A. |
| API surface | New effective_price field on Product GraphQL type; old price retained for one deploy cycle. |
| Read patterns | Product-detail pages will join pricing_rules per product → covered by new index. |
| Write patterns | Pricing admin UI writes kind = 'target_sell' rows. |
ALTER TABLE pricing_rules
ADD COLUMN kind text;
ALTER TABLE pricing_rules
ADD COLUMN target_price numeric(12,2);
CREATE INDEX CONCURRENTLY idx_pricing_rules_tenant_product_kind
ON pricing_rules (tenant_id, product_id, kind);ALTER TABLE ... ADD COLUMN (nullable, no default) takes
AccessExclusiveLock for milliseconds in PG ≥ 11. CREATE INDEX CONCURRENTLY takes ShareUpdateExclusiveLock, allows concurrent
reads/writes; expected duration ≈ 60 s on a 5 M-row table.DROP INDEX CONCURRENTLY idx_pricing_rules_tenant_product_kind;
ALTER TABLE pricing_rules DROP COLUMN target_price;
ALTER TABLE pricing_rules DROP COLUMN kind;-- Repeat in batches of 5,000 rows until no more rows return.
WITH batch AS (
SELECT id, tenant_id, (metadata->>'wholesale_price')::numeric AS wp
FROM catalog_products
WHERE metadata ? 'wholesale_price'
AND id > $last_id
ORDER BY id
LIMIT 5000
)
INSERT INTO pricing_rules (tenant_id, product_id, kind, target_price)
SELECT tenant_id, id, 'target_sell', wp
FROM batch
ON CONFLICT (tenant_id, product_id, kind) DO UPDATE
SET target_price = EXCLUDED.target_price;metadata.wholesale_price.(tenant_id, product_id, kind).ALTER TABLE pricing_rules
ADD CONSTRAINT uq_pricing_rules_tenant_product_kind
UNIQUE USING INDEX idx_pricing_rules_tenant_product_kind;pricing_rules.target_price. Outbox publishes
PriceChanged@v2 for every change.ALTER TABLE ... ADD CONSTRAINT upgrades the existing
unique index without table rewrite.metadata.wholesale_price still readable
(read-only) for one deploy cycle.ALTER TABLE pricing_rules DROP CONSTRAINT uq_pricing_rules_tenant_product_kind;ALTER TABLE pricing_rules
ALTER COLUMN kind SET NOT NULL;
UPDATE catalog_products
SET metadata = metadata - 'wholesale_price'
WHERE metadata ? 'wholesale_price';SET NOT NULL revalidates rows; brief
AccessExclusiveLock. Run after Phase 2 fully drained.ALTER TABLE pricing_rules ALTER COLUMN kind DROP NOT NULL;
(JSONB key cannot be restored — keep a backup of pre-Phase-4
metadata.wholesale_price snapshots in cold storage for 30 days.)effective_price resolver returns target_price when set,
falls back to price.target_price; deleting the rule restores price.pricing_rules.target_price even by id (RLS positive + negative).PriceChanged@v2 projector idempotently sets
Product.target_price on Neo4j; replaying the same event twice yields the
same node.target_price for the same product
→ second write wins, no torn read in pricing_rule_history./admin/pricing/target with same Idempotency-Key
twice returns the cached response.PriceChanged v1 → v2.
Payload delta:
{
"type": "object",
"properties": {
"price_type": { "enum": ["list", "compare_at", "target_sell", "effective"] },
"tenant_id": { "type": "integer" },
"product_id": { "type": "integer" },
"previous_amount": { "type": "string", "pattern": "^-?\\d+(\\.\\d+)?$" },
"new_amount": { "type": "string", "pattern": "^-?\\d+(\\.\\d+)?$" }
},
"required": ["price_type", "tenant_id", "product_id", "new_amount"]
}Consumers: Neo4j projection worker (must MERGE on (tenant_id, product_id, price_type)), analytics warehouse, notification worker.
Replay safety: idempotent — same payload re-applied yields the same node property.
## 2026-05-04 — pricing_rules.target_price (Option E)
- ADR-0010: selling price lives on `pricing_rules`, not `catalog_products`.
- Migration: 4-phase expand/contract (see SKILL).
- Backfill source: `catalog_products.metadata.wholesale_price` (8 % rows).
- Event: `PriceChanged` v1 → v2 with `price_type` discriminator.
- Neo4j: `Product.target_price` denormalized via `PriceChanged@v2` projector.Yes — ADR-0010: "Selling price lives in pricing_rules, not on
catalog_products". Drafted via adr-drafting skill, status Proposed.
Migration SQL above is conditional on ADR acceptance.
docs
skills
adr-drafting
commerce-database-architecture
graph-rag-boundary-review
mermaid-diagram-review
outbox-and-eventing-design
postgres-schema-introspection
schema-evolution-workflow