> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Pricing rules service

> Implementation details for the pricing rules store in stralos-api-rates (PRD 05 M2)

Pricing rules are the shared conditioned pricing-logic store used by rate options, day-rate schedule revisions, and day-rate schedule lines. The domain contract is defined in `@stralos-co-nz/domain`; this page covers storage, concurrency, and operational safety in `stralos-api-rates`.

## Data model

Pricing rules are stored in `rates_pricing_rules`. Each row represents one conditioned rule for one owner:

| Column | Type | Notes |
| - | - | - |
| `id` | uuid | Primary key. |
| `organisation_id` | uuid | Tenant isolation. |
| `owner_type` | varchar(30) | `rate_option`, `day_rate_schedule_revision`, or `day_rate_schedule_line`. |
| `owner_id` | uuid | UUID of the owning record. |
| `slot` | varchar(24) | `base`, `minimum_quantity`, `minimum_amount`, `modifier_additive`, `modifier_multiplicative`. |
| `condition` | varchar(40) | Condition token. Constrained to `^[a-z0-9]+([ _-][a-z0-9]+)*$`. |
| `rule_kind` | varchar(20) | `simple`, `tiered`, `matrix`, `fixed`, `cost_plus`, `stepped`, `minimum`, `modifier`. |
| `rule` | jsonb | Full rule payload, validated by domain on every write. |

The `(organisation_id, owner_type, owner_id, slot, condition)` tuple is unique.

Rate option owners always carry `condition = 'standard'`. Schedule revision owners cannot have a `minimum_quantity` slot. Tiered and matrix rules are only allowed on `rate_option` owners. Cost-plus rules are not allowed on `rate_option` owners.

Migration 0064 added `stepped` to `rates_pricing_rules_kind_check` and the base-slot biconditional. `rates_pricing_rules_stepped_owner_check` allows a stepped rule only on a `rate_option` owner, and `rates_pricing_rules_stepped_shape_check` is the database backstop for its JSON: a `steps` array of 1 to 50 objects, each with a string `key` and `label`, a numeric `amount` of at least 0 and `retired` absent or `true`, with at least one step not retired. Key format and uniqueness, label rules and the money scale are the domain validator's. `rates_pricing_rules_owner_valid` also refuses a stepped base beside a minimum or a modifier. See [Stepped base rules](https://help.stralos.app/internal/pricing-module.md#stepped-base-rules) for the rule shape.

### Must-pick options

A rate option priced by steps (a `stepped` base) or by a rate table (a `matrix` base, since migration 0065 and pricing 2.7.0) is must-pick: the estimator chooses on the line and the option's regular cost never applies, so a fixed must-pick option has a null `cost_rate`. `isManualPickOption` and `manualPickKindOf` in `src/lib/pricing-rules.ts` identify one.

The database holds the rule with a deferred constraint trigger pair, `rates_rate_options_stepped_cost_trg` on `rates_rate_options` and `rates_pricing_rules_stepped_cost_trg` on `rates_pricing_rules`, both calling `rates_rate_options_stepped_cost_valid`. A fixed option has a null cost if and only if it owns a base of kind `stepped` or `matrix`; anything else fails at commit with `ERRCODE 23514` and constraint `rates_rate_options_stepped_cost_check`. The triggers are deferred because writers change the option and its rules in either order inside one transaction. Migration 0064 introduced them for `stepped` and relaxed `rates_rate_options_cost_rate_check` to `basis = 'fixed' OR cost_rate IS NULL`. Migration 0065 widened the predicate to `matrix` and cleared the cost of every stored rate-table option, inside an owner-only `NO FORCE ROW LEVEL SECURITY` window so the update reaches every tenant's rows. The names were kept so existing clients still match.

The API guards return plain-language `422`s:

| Situation | Code |
| - | - |
| Option create or `PUT` sends a cost beside a stepped base or a rate table | `stepped_has_no_cost` or `rate_table_has_no_cost` |
| Rules write drops the must-pick base while the option still has no cost | `stepped_needs_cost` or `rate_table_needs_cost` |
| Standard option with live multiplier options becomes must-pick | `stepped_standard_has_multipliers` or `rate_table_standard_has_multipliers` |
| Rules write removes a step from a rule that stays stepped | `step_key_removed` |
| Day-rate schedule line mapped to a must-pick option | `stepped_rate_not_mappable` or `rate_table_not_mappable` |

The way out of must-pick is the option `PUT`, which sets `costRate` and `pricingRules` together. `src/lib/http.ts` maps the domain's `RateTableRowNotChosenError` and `StepNotPickedError` to a `422` carrying the domain sentence and its `code` (`rate_table_row_not_chosen` or `step_not_picked`).

### Rate table (matrix) rules

Users see a `matrix` base rule as a **Rate table**; `rule_kind` and `ruleKind` stay `matrix`. Since pricing 2.5.0 its `secondary` side has two shapes, and `src/routes/pricingRules.ts` accepts either through a strict union (`matrixRangesSideSchema` or `matrixNamedSideSchema`):

* **Ranges**: `kind` omitted or `"ranges"`, with `key`, `label`, `unit`, `bands` (1 to 12 inclusive lower bounds) and an optional `defaultBandIndex`. Every rule stored before named rows omits `kind`.
* **Named rows**: `kind: "named"`, with `key`, `label`, `rows` (1 to 12 `{ key, label }` entries) and an optional `defaultBandIndex`. Row keys come from `rowKeyFromLabel` (`20 MPa` becomes `20_mpa`).

`defaultBandIndex` is kept only so rules stored before pricing 2.7.0 still parse. A rate table has no default row, and pricing ignores the field.

Writes pass the rules to the domain validator unchanged, so its plain-language messages reach the 422 `issues` at the field's own path. `synchroniseRepresentativeCostRate` clears `costRate` to null for a must-pick option (stepped or rate table) and sets a tiered option's cost to its first band's price; it leaves other options alone.

`pricingRuleMetadata` in `src/lib/pricing-rules.ts` serves the rates-list chip as `ruleShape`:

| Base rule | `ruleShape` |
| - | - |
| `tiered` | `<N> bands` |
| `matrix` | `Rate table <rows>×<columns>`, rows from `matrixRowCount` |
| `fixed` | `fixed` |
| `stepped` | `<N> steps`, counting steps not retired |
| Other | `null` |

The same metadata serves `stepCount` and `stepAmountRange` for a stepped base and `matrixPriceRange` (lowest and highest cell price, per charged unit) for a rate table; each is `null` for other kinds. `fromUnitPrice` is `null` for a stepped base.

The day-rate export describes a matrix rule as `Rate table by quantity and <side label>`. The Rates Library's pricing rule editor labels the base rule **Rate table** and builds both ranges and named sides.

Day-rate schedule owners (`day_rate_schedule_revision` and `day_rate_schedule_line`) keep only the base rule at condition `standard`. Migration 0061 added `rates_pricing_rules_day_rate_base_only_check`, a validated database constraint that enforces this:

```sql theme={null}
CHECK (owner_type = 'rate_option'
       OR (slot = 'base' AND condition = 'standard'))
```

Any write that tries to store a minimum, a loading, or a non-standard condition rule on a day-rate owner is rejected with `SQLSTATE 23514`. At the API layer, the guard `assertDayRateRulesBaseOnly` runs on raw input before domain validation, returning `422` with code `day_rate_rules_base_only` and a message explaining that minimums and loadings are applied per work item instead. The migration removed all pre-existing minimum and loading rules from day-rate owners across every environment.

### Owner lock table

`rates_pricing_rule_owner_locks` holds a per-owner mutex row. A trigger on `rates_pricing_rules` upserts this row on every rule mutation. At `READ COMMITTED` isolation this serialises concurrent writers for the same owner; at stronger isolation PostgreSQL aborts a stale concurrent upsert. The service also acquires this row lock explicitly via `assertAndLockRuleOwner` before any rule replacement.

## Versioning

The version of a rule set is the SHA-256 of the canonical JSON of the sorted rules. Canonical JSON sorts object keys lexicographically and represents numeric money fields at their declared decimal scale (unit prices at 2 dp, quantities at 3 dp, percentages at 3 dp, multiplicative factors at 4 dp). This ensures the version is byte-stable across serialisation round-trips.

`pricingRuleVersion(rules)` in `src/lib/pricing-rules.ts` computes the version. `sourceFingerprint` in the option response uses the same function applied to the effective (inherited + owned) rule set.

## Concurrency and locking order

Every mutation path that touches a condition-bearing row — a rate option, a pricing rule, or a day-rate schedule loading — must acquire the organisation-scoped advisory lock `stralos:pricing-rules:rekey:<organisationId>` first.

This lock is taken by:

* `lockPricingRuleVocabulary` in the HTTP handlers (before any row lock or schedule advisory lock).
* `rates_condition_rekey_writer_lock` database trigger on every insert, update, or delete to `rates_pricing_rules` and `rates_rate_options`. Schedule loadings are pricing-rule rows, so the same rule trigger covers them.

The condition re-key endpoint also takes this lock first. This establishes a single global acquisition order: org mutex → schedule publication advisory lock → row lock. Acquiring them in a different order would introduce a deadlock window between a concurrent writer and a re-key.

## Condition re-key

The re-key operation migrates a condition token value across all mutable dependants atomically. It produces an append-only audit record in `rates_condition_rekeys`.

### Migration scope

The re-key scans for:

* **Mutable options** — every live rate option in the organisation with the old condition. These are updated to the new token.
* **Draft loadings** — pricing rules on draft schedule revisions or lines that reference the old condition. These are updated to the new token. Note: day-rate schedule owners are base-only as of migration 0061, so no day-rate loadings remain to re-key.
* **Published loadings** — pricing rules on published (current or superseded) revisions. These are preserved with the old token in the published record. The PRD 06 publication path reads the `rates_condition_rekeys` audit table to carry the new token forward when a published rule set is copied into the next revision.

### Plan hash

The dry-run step builds the full migration plan and hashes it. The commit step must present the same hash. If any dependent record changed between the dry run and the commit, the plan hash no longer matches and the operation returns `409 Conflict`. The caller must repeat the dry run to get a fresh plan.

### Collision types

A dry-run response with non-empty `collisions` will not commit.

| Type | Meaning |
| - | - |
| `rate_option_unique` | Re-keying the option would create two options with the same `(item, source, location, condition)` tuple. |
| `pricing_rule_unique` | Re-keying a draft rule would create a duplicate `(owner, slot, condition)`. |
| `reserved_standard` | The standard option is depended on by multiplier options. Changing its condition away from `standard` would orphan those multipliers. |

## Pricing rule safety jobs

Three scripts check the integrity of stored pricing rules outside the HTTP request path. They require the `stralos_platform_rates` role (`NOSUPERUSER BYPASSRLS`) and the `PRICING_RULES_DATABASE_URL` environment variable.

| Script | Purpose |
| - | - |
| `rules:check` (`scripts/rules-check.ts`) | Validates every stored rule against the domain schema and sweeps orphaned owner-lock rows. Runs on a schedule and before every deployment promotion. |
| `rules:validate` (`scripts/rules-validate.ts`) | Runs domain validation only, without the orphan sweep. Use for ad-hoc investigation. |
| `rules:sweep-orphans` (`scripts/rules-sweep-orphans.ts`) | Removes `rates_pricing_rule_owner_locks` rows that no longer have any corresponding rule rows. |

The `RULES_SAFETY_MANIFEST.json` manifest records the service name, database access class, and the command to run for CI safety checks.

## Inherited rules and effective rule resolution

A fixed rate option uses its own rule set. A multiplier option merges its own rules with scaled rules inherited from the Standard option for the same item, source, and location. The multiplier's own rules take precedence for the same slot and condition; the Standard's rules fill the remaining keys.

Schedule line rules inherit from the parent revision, with durable override lineage tracking additions, changes, and deletions independently. The day-rate schedule handlers resolve the effective set before API responses and cost calculations.

Rule inheritance is resolved at read time; inherited rules are not persisted onto the inheriting owner.

## Numeric precision

All numeric rule values are stored at their canonical scale:

| Field | Scale |
| - | - |
| `minQuantity`, `value` (minimum\_quantity slot) | 3 decimal places |
| `unitPrice`, `value` (minimum\_amount slot), `value` (modifier\_additive slot) | 2 decimal places |
| `percent` (cost\_plus) | 3 decimal places |
| `value` (modifier\_multiplicative slot) | 4 decimal places |

The canonical JSON serialiser applies `value.toFixed(scale)` at the canonical-hash step only, not at storage time. The database stores the submitted float. The version hash is therefore stable across repeated round-trips.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.