Skip to main content
Project rates are project-scoped rate options: one row in rates_rate_options carrying a project_id. This page covers the write path, the re-price outbox, the ProjectRatesChanged event, the estimate-reprice service login, estimate-scoped project rates (PRD 210), the saved-version restore endpoint, and prices converted from the legacy override model. The public API contract is documented in the Rates API reference under the Project rates and Estimate rates sections.

Data model

A project rate is a rates_rate_options row with a non-null project_id. Two kinds exist:
  • replacement — the rate item is a library item (rates_rate_items.project_id is null). The project rate overrides the library price for this project.
  • project_only — the rate item is owned by this project (rates_rate_items.project_id equals the option’s project_id). A project-only item that loses all current prices is soft-deleted.
Project rates are always basis: fixed. Every project rate option carries additional columns: The same estimate_id column was added to rates_rate_options, rates_project_rate_rules, rates_project_rate_settings, rates_project_rate_reprice_outbox, and rates_project_rate_versions in migration 0056.

Write path

All write handlers in src/routes/projectRates.ts follow this sequence:
  1. Validate outside the transaction: Zod schema parse, project existence check via api-global, supplier/location pair validation, and unit validation against the local category projection (see Rate category projection). Validation failures throw rather than returning inline from within a handler, ensuring no partial commit from a return reply.status(4xx) inside an async callback.
  2. Acquire the vocabulary lock — lockPricingRuleVocabulary takes the org-scoped advisory lock stralos:pricing-rules:rekey:<organisationId>. This is the same lock every rate option mutation takes.
  3. Write rows, enqueue one re-price row, and enqueue one ProjectRatesChanged event in the same transaction. The option write, the re-price outbox insert and the event_outbox insert commit atomically.
  4. Deliver pending re-prices after commit: repriceAfterWrite calls deliverPendingReprices, which sends every undelivered row for this project to api-estimate. A failed delivery leaves the rows pending and never fails the write. After that synchronous attempt, publishAfterCommit publishes the enqueued event.

Re-price outbox

Migration 0039 added rates_project_rate_reprice_outbox. Each row records one change event for a project option: deliverPendingReprices collects all undelivered rows for a project (up to 500 per call) and POSTs them to api-estimate’s POST /api/estimate/projects/:projectId/project-rate-reprice. The caller’s bearer token is forwarded — there is no rates-to-estimate service account.

Delivery outcomes

There is no scheduled sweep of this outbox. A pending row is retried by the next write’s delivery for the same project, by POST /project-rates/reprice-pending, or, once its event is published, by api-estimate’s ProjectRatesChanged consumer, which re-prices from current rate state. A row counts towards pendingRepriceCount only while it is undelivered and its event has not been published (event_outbox.sent_at is null). Once the event is on the bus, the consumer owns the retry. Rows with a null event_id stay pending until HTTP delivery succeeds. The SSM read for ESTIMATE_API_URL runs inside the 15-second delivery budget. A missing configuration is recorded as not configured to distinguish it from an outage.

SSM URL resolver

src/config/estimate-api-url.ts resolves the estimate endpoint:
  • It first reads ESTIMATE_API_URL from the environment variable.
  • If absent, it reads the SSM parameter at the path defined by ESTIMATE_API_URL_SSM_PARAMETER.
  • If neither is set, or the SSM parameter holds no usable value, the resolver returns kind: "not_configured" regardless of environment. An SSM read failure or timeout returns kind: "unavailable".
The resolver shares the delivery AbortSignal so the SSM read counts against the 15-second delivery budget. resolveEstimateApiUrl (used outside the delivery path) throws a configuration error naming both variables when neither is set, rather than guessing a localhost address.

Override mirrors (migration 0040)

Migration 0040 converted each legacy override into a project rate option with the same id. Estimate snapshots retain projectRateOverrideId; it identifies the winning project option, so existing snapshots need no remap. S6b removes the legacy table and all compatibility writes. The conversion details below describe the existing migrated prices, which remain independently editable project options.

Mirror fields

A mirror carries from its library option L:
  • scope, supplier_id, location_id, source_item_code, condition, unit, conversion factor, and orientation.
A mirror carries from the override:
  • project_id, rate_item_id, cost_rate = override_final_rate, notes.
A mirror always has basis: fixed. It also carries L’s effective non-base pricing rules (minimums and modifiers), because an override replaced only the base unit price and minimums still applied.

Base rule carried on a fixed-basis library option

For a library option with a fixed base rule, the mirror carries that rule without companionOfOptionId (the mirror is no one’s companion). This is necessary because pricing.resolveBase prices a fixed base once at the unit price, regardless of quantity. Without the carried base, a $280 lump-sum option at quantity 3 would price at $840 instead of $280. minimum_quantity and modifier_additive rules are dropped when a fixed base is carried, because coreResolve refuses the combination and migration 0040 enforces the same rule in SQL. For library options with other base kinds (simple, tiered, matrix, cost_plus), no base rule is carried — cost_rate = override_final_rate with no base rule produces the correct money for those kinds.

Mirror lifecycle

A mirror born from an already-retired library option starts retired. A mirror whose library option was current when the mirror was created stands on its own: closing or retiring L afterwards does not retire the mirror. Rule edits on L do not propagate to the mirror after creation. If a library option starts pricing again into a slot that a mirror already holds as current, the /project-rates write returns 409 current_exists rather than duplicating a current rate in that slot.

Legacy retirement (S6b)

Migration 0043 validates every remaining legacy row against its converted option across all organisations, then drops only rates_project_rate_overrides, without CASCADE. Missing or stale converted prices abort the transaction. FORCE RLS remains enabled on rates_rate_options. The migration preserves existing project option ids, prices and independent pricing rules. Library rule edits are not copied back onto project prices. The legacy GET and write routes now return 404, and are absent from OpenAPI. healProjectMirrors, reverse compatibility writes, library-triggered mirror revival, the transition tripwire and pnpm parity:overrides are removed. project-rate-lifecycle.ts retains current-slot checks and project option retirement. Unit and conversion-factor edits on converted prices use the normal project-price validation. Deploy current readers in api-estimate, feature-estimate and feature-project-schedule before applying this cutover. Afterwards, rolling rates back to code that queries the dropped table requires an explicit schema/data restoration first.

Mirror companions

Library option L’s companion options are served under the mirror in responses. The mirror replicates L’s children with the fixed base rule and rewrites the envelope fingerprint to match the mirror’s effective rule set. This rewrite happens in the response only and is not persisted.

Quantity bands on imported project rates

A project rate produced by the rate importer may carry a tiered base pricing rule. That rule is the rate’s full price schedule (for example, $60/hr for the first 10 hours and $50/hr beyond that), and the scalar costRate on the option row is only its representative first-band price.

Scalar edits are refused

PUT /project-rates/:optionId checks whether the option already owns a tiered base rule before applying a costRate change. If one exists, the update is refused with 422 and the machine-readable code quantity_bands_require_import. The rule is the price; editing just the scalar would leave the displayed cost and the stored pricing schedule out of sync. Re-importing the rate through POST /project-rate-imports is the correct path when the band schedule itself needs to change.

Promote preserves the band schedule

POST /project-rates/:optionId/promote moves a project-only item into the rates library. The promote path creates a new library option and, when the project option carries a tiered base rule, copies that rule onto the library option using applyImportedTieredRule from src/lib/imported-pricing-rules.ts. The copy writer replaces only the base slot and keeps every other rule (minimums, modifiers) already on the option unchanged. Without this step, promoting a rate imported with quantity bands would silently produce a flat-rate library option at the first band’s price. applyImportedTieredRule is the sole writer of rates_pricing_rules for both the import commit path and the promote path. It locks the owner row, merges the new base rule with the option’s existing non-base rules, and writes the result in one atomic step inside the caller’s transaction.

Must-pick project rates

A project rate whose option owns a stepped or matrix base is must-pick and has no costRate, like its library counterpart. PUT /project-rates/:optionId treats the two kinds differently:
  • Stepped: a costRate different from the stored one is refused with 422 stepped_rate_cost_edit (“This rate is priced by steps. Please change it in Rates.”). Steps are edited on the rate option.
  • Rate table: a supplied costRate supersedes the table. The handler removes the option’s matrix base rule in the same transaction, under the pricing-rule vocabulary lock, so the deferred rates_rate_options_stepped_cost_check finds a costed option with no must-pick base at commit. An update without costRate keeps the table and the null cost.
Promotion copies a stepped project rate’s steps onto the new library option.

Slot uniqueness and conflict codes

The database key rates_rate_options_one_current ensures at most one current project rate per (item, source, location, condition, project) slot. The write path calls assertProjectSlotFree before inserting to name the holder in the 409 response body. The index’s own 23505 error is mapped to the same current_exists code as a race-lost fallback. The name uniqueness index 23505 on rates_rate_items for the promote path, and the scope trigger 23514 for project-only items, are also mapped to coded 409 responses.

ProjectRatesChanged publisher

Every project-rate write, and every commit through POST /project-rate-imports, enqueues one ProjectRatesChanged v1 event. A write with more changes than api-common’s PROJECT_RATES_CHANGED_MAX_CHANGES is split across several events, each with its own version. The event is thin: ids only (organisation, project, and each changed item and option with its change type), the write reason (project_rate_write or rate_import), and the project’s new rateSetVersion.
  • rateSetVersion: migration 0051 added rates_project_rate_versions, a per-project counter. Each event bumps it with INSERT … ON CONFLICT DO UPDATE … RETURNING, and the row lock serialises concurrent writes to the same project, so the version strictly increases across committed writes. Consumers dedupe on eventId only and never drop an event by version.
  • Outbox and publish: the envelope is written to event_outbox in the write’s transaction. After commit, publishAfterCommit calls publishNow, which sends it to EventBridge through createInlineEventBridgeClient with a 3-second abort timeout (INLINE_PUBLISH_TIMEOUT_MS). An event that is not sent in time stays in event_outbox.
  • Flush Lambda: src/event-flush-lambda.ts runs on a rate(1 minute) schedule and publishes any unsent outbox rows without the inline timeout. A CloudWatch alarm fires if the schedule stops reaching the Lambda.

Estimate-scoped project rates

Migration 0056 (PRD 210 phase 1, “expand”) makes project rates estimate-scoped. Every project-keyed table gains a nullable estimate_id column. The project_id column stays as the denormalised owner for project access, RLS, and library promotion.

Identity uniques widened by estimate

The four identity unique indexes gain COALESCE(estimate_id, '00000000-…'::uuid) as their last key column — the same names, strictly looser. This means an estimate copy can hold the same item names, option slots, rule scopes and settings as its source inside the same project without colliding. The affected indexes are:
  • rates_rate_items_project_name_unique
  • rates_rate_options_one_current
  • rates_project_rate_rules_org_project_scope_live_unique
  • rates_project_rate_settings_org_project_unique
Because the old key is a prefix of the new one, existing lookups by (org, project, …) remain index conditions. Migration 0034’s normalised-name expression character class is preserved exactly.

Stamp trigger

rates_stamp_estimate_scope is a BEFORE INSERT (and BEFORE UPDATE OF project_id on rate items) trigger on all six project-keyed tables. It runs as the writer, under RLS:
  • If project_id is null (library promotion), it clears estimate_id.
  • If estimate_id is null but project_id is set, it reads rates_project_default_estimates for the project and stamps that estimate.
  • Rows that already carry an estimate_id are unchanged.
This means writers that do not yet send an estimate_id — including callers that omit the new field — land in the project’s default estimate once backfilling is complete, maintaining the same uniqueness semantics as before.

Migration 0056 compatibility

Migration 0056 is additive and requires no cutover marker. No existing row changes because estimate_id is nullable and the index rebuild is looser. Estimate-less writes pass the trigger (it stamps the default or leaves null when no default is recorded yet) and target the same identity slots as before. Phase 2’s contract migration (0059) later made estimate_id mandatory on every project row; writers now resolve their estimate first (see Resolving the write estimate).

Supporting tables

rates_project_default_estimates records which estimate is a project’s default — the estimate that bare ?projectId= reads resolve to and that the trigger stamps on estimate-less writes. Two routes write to this table:
  • POST /estimate-rates/backfill records the default once per project. A repeat call with the same estimate id is a no-op (stamped, zero counts); a call with a different estimate id is refused (estimate_mismatch). The default cannot be changed through this route after it is recorded.
  • PUT /estimate-rates/defaults moves the default to a named estimate. api-estimate calls this after every make-primary commit and after an award commit, so the default follows the primary. The call is best-effort — a failure is logged and never fails the request. A 10-minute sweep (sweepRatesDefaults in src/estimates/ratesDefaultSync.ts) re-sends the primary of every project whose primary row was written within the last 26 hours, retrying any lost push. The PUT is idempotent and api-rates’ response is unchanged when nothing moves.
Migration 0060 grants UPDATE on this table to the runtime and platform roles so the PUT can write. The possible PUT /estimate-rates/defaults outcomes per project are: Copy-job guard. A copy job (save version, duplicate, restore-keep, Delivery copy after an award) calls the backfill before copying rates. The guard refuses only when api-rates records no default at all for the project (409 rates_default_estimate_mismatch). It no longer compares the recorded default against the current primary — a lagging default after make-primary or an award is expected and does not block any copy. rates_estimate_rate_copies records the idempotency of POST /estimate-rates/copy. One row per target estimate. A replay with the same correlationId returns the stored id maps; any other request for an already-filled target is refused. The row also carries discarded_at (migration 0058), stamped by POST /estimate-rates/discard when the copy’s target estimate is abandoned. A discarded copy refuses a later POST /estimate-rates/copy for the same target with 409 copy_target_taken. rates_estimate_rate_restores records the idempotency of POST /estimate-rates/restore (migration 0057). The key is (organisation, target_estimate_id, correlation_id) because a target may be restored many times. A replay with the same (target, correlationId) returns the stored id maps and writes nothing. The same correlationId naming a different source estimate or project is refused with 409 restore_correlation_reused.

Phase 2 contract (migration 0059)

Migration 0059 (PRD 210 phase 2, “contract”) enforces that every project row carries an estimate_id. It adds CHECK (project_id IS NULL OR estimate_id IS NOT NULL) — validated — on all six project-keyed tables, moves rates_project_rate_versions_pk from (organisation_id, project_id) to (organisation_id, estimate_id), and adds the estimate dimension to the reprice outbox’s pending-delivery index. A pre-check in the migration body refuses to run if any project row still has a null estimate_id. The one-off scripts/estimate-scope-precheck.mjs that ran before the release carrying 0059 has been deleted. If the migration has already been recorded as failed, a new commit is required — the migration runner does not re-run a failed migration. Contract window. During the seconds between the migration and the alias flip, the previous build’s bumpRateSetVersion targets (organisation_id, project_id) on rates_project_rate_versions, whose primary key has just moved to (organisation_id, estimate_id). Postgres answers 42P10 and every project-rate write of the old build returns 500 until the flip. Library writes and reads are unaffected.

Resolving the write estimate

src/lib/writeEstimate.ts is the single place every project-rate write learns which estimate it writes to. The answer, in order:
  1. An explicit estimateId from the caller is used as-is (lower-cased).
  2. When absent, the project’s recorded default estimate from rates_project_default_estimates is used.
  3. When absent and no default is recorded, GET /api/estimate/projects/:id/estimate is called on the caller’s behalf (15-second timeout). A 404 or 409 from api-estimate returns 409 estimate_required to the caller. The fetched id is recorded as the default — first writer wins.
The explicit path (1) is not validated against api-estimate. RLS project membership already confines the caller to the project; rows written under an id api-estimate never created are orphans inside the caller’s own project.

POST /estimate-rates/restore

POST /estimate-rates/restore implements the “keep saved prices” path of a saved-version restore (PRD 210). api-estimate calls this endpoint when restoring a frozen saved version onto the working estimate: it makes the working estimate’s project rates exactly equal to the saved version’s so that a subsequent re-price reads the saved prices instead of the current catalogue. The operation runs in a single transaction:
  1. Every live project row of the target estimate is soft-deleted with one timestamp — project-only rate items are set to deleted, current rate options are retired (inactive, valid_to), and live preference rules are stamped deleted_at.
  2. The source estimate’s rows are copied onto the target using the same logic as POST /estimate-rates/copy (src/lib/estimate-rate-copy.ts): project-only items, all project options (including retired ones), those options’ pricing rules, and live preference rules.
  3. The target’s pricing-location settings are replaced with the source’s. If the source has no live settings row, the target’s row is soft-deleted (see Settings soft-delete).
The endpoint deliberately enqueues no ProjectRatesChanged event, writes no re-price outbox row, and does not bump the project’s rate-set version. api-estimate lands its own estimate rows consistent with the restored rates in the same operation, so a re-price event would undo the “keep saved prices” intent. Request body: Response shape: Status codes: 201 on a new restore; 200 on a replay. 409 if the correlationId was already used for a different source or project (restore_correlation_reused). 409 if the project has not been backfilled to its estimates yet (project_not_backfilled). 422 if the source estimate holds no project rates for this project (restore_source_unknown) or the target is not an estimate of this project (restore_target_unknown). Concurrency: The endpoint takes the same advisory lock as POST /estimate-rates/copy on the target estimate (stralos:estimate-rate-copy:<targetEstimateId>), so two concurrent restores, or a copy racing a restore, into the same target serialise rather than interleave. Caller: estimate-rates service login only (same as backfill and copy). Any other caller receives 403 estimate_rates_caller_required.

Settings soft-delete (migration 0058)

rates_project_rate_settings holds one live row per estimate recording the estimate’s pricing-location context. Before migration 0058, restoring a saved version that had no settings row (meaning it was deriving its location automatically, with no saved set) hard-deleted the working estimate’s row — violating the app-wide rule that DELETE means soft-delete. Migration 0058 adds deleted_at timestamptz to rates_project_rate_settings and replaces the unique index with a partial index on live rows (WHERE deleted_at IS NULL). The partial index allows a restore to insert a new live row beside a dead one, as migration 0056 did for rules. Every read of the settings row — settings GET, derive dry-run, rule-set lock and update, copy source, restore target — filters deleted_at IS NULL. When restoreSettings finds no live row on the source, it soft-deletes the target’s live row (sets deleted_at) rather than removing it. The dead row reads the same as no row — the estimate derives its location automatically — but it is recoverable and consistent with the soft-delete ruling. A NULL-context row is not equivalent to no row: the schema CHECKs admit a null-context row only with context_source = 'none' (the user explicitly chose no pricing location), and the settings GET answers a row with both a context and a settingsUpdatedAt where it answers no row with neither.

estimate-rates service login

api-estimate mints an estimate-rates service token for the writes it makes in api-rates without a user context: the backfill, the default-estimate update, the estimate copy, the saved-version restore, and the discard of an abandoned copy. It also uses the token for the project day-rate set routes (initialise, list and move a set, and read a revision’s lines). refuseOutsideEstimateRatesWrites (src/auth/serviceCallers.ts) admits that token only onto those routes; any other route accessed with this login receives 403 estimate_rates_only. The writes’ own gate (requireEstimateRatesCaller) refuses every other caller with 403 estimate_rates_caller_required. Token properties:

estimate-reprice service login

api-estimate’s ProjectRatesChanged consumer re-prices with the same five reads a user-driven re-price makes, using a minted estimate-reprice service token instead of a user bearer. refuseOutsideEstimateRepriceReads (src/auth/serviceCallers.ts) admits that token only when it carries the rates:reprice-read scope and an organisationId, the request is one of these six reads (the five per-item reads plus their batch form), and projectId is a valid UUID: Anything else, including any write, returns 403 with code reprice_read_only. The gate exists because the service otherwise admits any org-scoped service token on any route. The token acts for no user, so project scope comes from the read’s projectId rather than api-global’s access-scope lookup, and the org GUC still confines rows to the token’s organisation. The api-global project existence check is skipped for this caller: an unknown project returns an empty list, which the re-price treats as 422 no_project_rate_changes and acknowledges. The token’s subject is service:estimate-reprice, its audience covers stralos-api-rates and stralos-api-global, and it is tenant-mode (the organisation is in the token, with no x-forwarded-user).

Authentication

Project-scoped rows are additionally gated by the project_membership RLS policy introduced in migration 0046. For each request, runScoped calls api-global’s /api/global/projects/access-scope endpoint to resolve the caller’s accessible project IDs and sets them as the app.project_ids transaction GUC. The policy blocks reads and writes on any row whose project_id is not in that set. A user who is not a member of a project cannot read or modify that project’s rates, even within their own organisation. See Rates project membership access control.