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.
A new productRules spec type, a generic when → then rule document, and three v3Admin endpoints: read, upsert one rule, delete one rule.
Writes are draftless. Every upsert/delete creates an immutable OrgSpecVersion and advances the org global-version pin in the same transaction.
The only gate is the document schema: strict fields, allowed ops, op/value pairing, product predicate shape, non-empty then, unique ids.
No semantic validation, no conflict/cycle checks, no catalog-aware dead-predicate checks, no evaluation endpoint, and no client changes.
1. Why this exists
- 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.
- Creates a versioned
productRulesdocument 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.
then actions like preselect, dependsOn, or hide.
2. What changes
2.1 New rule document shape
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
productRules body plus the pinned version token.
FOR UPDATE serializes with other publish paths.
OrgSpecVersion, then advance global pin.
readProductRules(orgIdOrSlug)mutateProductRules({ baseVersion, mutate })ProductRulesWriteConflictErrorProductRulesValidationError
productRulesadded toSpecType.SpecVersionMapallows optionalproductRules?.- No default productRules version is added to older manifests.
getPinnedSpecVersionexposes 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
dealops3/rules/types.tsdealops3/productRules/types.tsproductRules/__tests__/grammar.test.ts
dealops3/productRules/store.tsdealops3/versioning/store.tsdealops3/versioning/types.ts
v3Admin/getProductRules.tsv3Admin/upsertProductRule.tsv3Admin/deleteProductRule.tsv3Admin/_router.ts
spec-versioning.prisma20260710184334_add_product_rules_spec_typepackages/prisma/seed.ts
e2e_tests/orgs/product-rules-test/tests.tse2e_tests/seed/runner.tsserver_tests.yml
3. How it works
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.
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 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.
- 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.
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
Covers launch rules, every supported condition op, byte-for-byte round trip, strict shape failures, product predicate failures, empty then, and duplicate ids.
Runs through the real caller path: create, read back, update in place, delete, unknown delete 404, schema rejection, stale token conflict, rollback token movement.
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
- No rule evaluation: quotes will not auto-select, hide, substitute, require, or block products from these stored rules.
- No semantic validation: cycles, conflicts, dead predicates, missing anchors, ambiguous substitutions, and unknown action-key checks are stacked for the follow-up PR.
- No client/UI changes and no new REST routes; this is server-side
v3AdmintRPC only. - No changes to
approvals/**,attributes/**, or existing approval rule semantics. - No draft path for product rules;
OrgSpecDraftremains for other specs, but productRules writes publish immediately. - No default
productRules: 1pin in legacy manifests; absent means “no product rules published yet”.
5. Risks / rollback / open questions
Because then is intentionally open, this PR can persist rules whose action keys or payloads the future evaluator rejects.
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.
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.