Skip to main content
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: 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 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 422s: 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: 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:
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.

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. 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: 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.