Product selection rules: draftless CRUD storage

This PR adds the server-side storage and tRPC CRUD layer for Dealops 3 product rules, with schema-only validation and no runtime rule evaluation yet.

Author: @pk675 PR: dealops#6319 Branch: main ← pk/product-rules State: open Files: 19 Diff: +1636 / -26 Surface: server tRPC + Prisma

What it adds

A new productRules spec type, a generic when → then rule document, and three v3Admin endpoints: read, upsert one rule, delete one rule.

Write model

Writes are draftless. Every upsert/delete creates an immutable OrgSpecVersion and advances the org global-version pin in the same transaction.

Validation boundary

The only gate is the document schema: strict fields, allowed ops, op/value pairing, product predicate shape, non-empty then, unique ids.

Not included

No semantic validation, no conflict/cycle checks, no catalog-aware dead-predicate checks, no evaluation endpoint, and no client changes.

Generic rule grammar
Product rules store
V3 versioning
v3Admin tRPC
Prisma spec type
Tests + seed

1. Why this exists

Problem today
  • Product-selection behavior is scattered across product ID lists, flags, display config, and org-specific server code.
  • ID lists do not survive V3 catalog fan-out cleanly.
  • Admins do not have a single rule document to read or edit.
This PR’s slice
  • Creates a versioned productRules document per org.
  • Stores rules using one generic grammar shared by future rule-backed features.
  • Ships CRUD only; the RFC’s validation/evaluation layer is stacked next.
Scope line: this is a pure-storage milestone for the Product Selection Rules RFC. The PR makes rules persistable and versioned before anything interprets then actions like preselect, dependsOn, or hide.

2. What changes

2.1 New rule document shape

Generic rule grammar

The core type is introduced in apps/server/src/dealops3/rules/types.ts. Product rules are aliases over that generic shape in dealops3/productRules/types.ts.

{
  id: "conditional-small-deal",
  description?: "Optional human label",
  when: [
    { field: "opportunity.amount", op: "lt", value: 50000 }
  ],
  then: {
    preselect: { sku: "small-deal-support" }
  }
}
Part Accepted in this PR Meaning
field quote.*, opportunity.*, sku.*, special quote.products Only format/entity validation; no org-specific term or field registry lookup yet.
op equals, lt, in, contains, etc. Operator names mirror the shared evaluator vocabulary verbatim.
value Scalar, array, range tuple, or product predicate depending on op. Schema enforces shape and pairing, e.g. between needs [min,max].
then Any non-empty object. Open by design. Storage does not validate action keys or payload meaning.

2.2 New draftless store

1 Read pinned doc Load current productRules body plus the pinned version token.
2 Mutate one rule Upsert replaces by id; delete removes by id and 404s if absent.
3 Parse schema Reject malformed docs before persistence.
4 Lock org row FOR UPDATE serializes with other publish paths.
5 Version + publish Save OrgSpecVersion, then advance global pin.
Store API
  • readProductRules(orgIdOrSlug)
  • mutateProductRules({ baseVersion, mutate })
  • ProductRulesWriteConflictError
  • ProductRulesValidationError
Versioning changes
  • productRules added to SpecType.
  • SpecVersionMap allows optional productRules?.
  • No default productRules version is added to older manifests.
  • getPinnedSpecVersion exposes the rollback-aware token.

2.3 New tRPC endpoints

Endpoint Type Input Returns Error mapping
v3Admin.getProductRules query {} { doc, version } NOT_FOUND when V3 not configured.
v3Admin.upsertProductRule mutation { rule, baseVersion } Updated full doc and new version. BAD_REQUEST for schema failure; CONFLICT for stale token.
v3Admin.deleteProductRule mutation { ruleId, baseVersion } Updated full doc and new version. NOT_FOUND for unknown id; CONFLICT for stale token.

2.4 File map

Grammar
  • dealops3/rules/types.ts
  • dealops3/productRules/types.ts
  • productRules/__tests__/grammar.test.ts
Storage
  • dealops3/productRules/store.ts
  • dealops3/versioning/store.ts
  • dealops3/versioning/types.ts
API
  • v3Admin/getProductRules.ts
  • v3Admin/upsertProductRule.ts
  • v3Admin/deleteProductRule.ts
  • v3Admin/_router.ts
DB + seed
  • spec-versioning.prisma
  • 20260710184334_add_product_rules_spec_type
  • packages/prisma/seed.ts
E2E + CI
  • e2e_tests/orgs/product-rules-test/tests.ts
  • e2e_tests/seed/runner.ts
  • server_tests.yml

3. How it works

Read behavior

For an org with no rules, getProductRules returns { rules: [] } and version: 0.

For an org with rules, the body is parsed with productRulesDocSchema before it leaves the store.

Upsert behavior

Input is parsed as one ProductRule. If the id exists, the rule is replaced in place and ordering is preserved.

If the id is new, it appends to doc.rules.

Delete behavior

Delete removes exactly one rule by id and publishes the remaining document.

Deleting a missing id throws NOT_FOUND with the rule id in the message.

3.1 Concurrency token is the global-version pin

The important design choice: baseVersion is not “latest row in OrgSpecVersion”. It is the productRules version pinned by the org’s current global version.

Why that matters
  • A global rollback can move the pin backward without creating a new productRules row.
  • An editor holding the pre-rollback token must not write over the rollback.
  • The store checks the token once before the lock and again inside the locked transaction.
Protected sequence
SELECT Organization FOR UPDATE
pinned = getPinnedSpecVersion(...)
if pinned !== baseVersion:
  throw CONFLICT

version = saveSpecVersion(productRules, doc)
createGlobalVersion({ productRules: version })

3.2 Schema gate details

Rejected case Example Reason
Stray fields { extra: 1 } Rule and condition objects are strict.
Unknown field entity pricing.total Only quote, opportunity, and sku are accepted.
Wrong products pairing quote.products equals "x" quote.products only supports contains with a product predicate object.
Bad range between [9, 1] Range must be min <= max.
Empty action object then: {} A rule must do something syntactically, even though action meaning is not interpreted yet.
Duplicate ids Two rules with affinity-terminal-activation Uniqueness is enforced at document level.

3.3 Test coverage added

Grammar unit tests
14Mocha cases

Covers launch rules, every supported condition op, byte-for-byte round trip, strict shape failures, product predicate failures, empty then, and duplicate ids.

Real tRPC e2e
9launch rules written

Runs through the real caller path: create, read back, update in place, delete, unknown delete 404, schema rejection, stale token conflict, rollback token movement.

CI seed wiring
1new org fixture

Adds Product Rules Test Org, registers it in the e2e seed runner, and adds the suite to server_tests.yml.

4. What it doesn't change

Explicit non-goals in this PR

5. Risks / rollback / open questions

Rollback behavior: the database migration only adds a Prisma enum value. Rolling back application behavior is mostly “stop calling the new endpoints” because no runtime evaluator consumes the stored documents yet. Existing global-version rollback is supported and tested for productRules pins.
Risk: pure storage stores future-bad semantics

Because then is intentionally open, this PR can persist rules whose action keys or payloads the future evaluator rejects.

Expected Follow-up validation owns this
Risk: versioning optionality

Adding an optional spec pin touches shared versioning code paths. The PR avoids defaulting productRules because that would make older global versions point at a future first rules version.

Covered by e2e rollback
Review note: naming drift

The workflow/test comments mention “engine” in a few places, while the PR scope is storage only. That does not change behavior, but reviewers may want labels/comments tightened to avoid implying evaluation shipped.

Open review nit
Open questions move to the stacked PR: conflict hierarchy, evaluation response shape, preselect transition behavior, condition-field registry validation, and per-org migration/parity checks are designed in the RFC but not implemented here.