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 arates_rate_options row with a non-null project_id. Two kinds exist:
replacement— the rate item is a library item (rates_rate_items.project_idis 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_idequals the option’sproject_id). A project-only item that loses all current prices is soft-deleted.
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 insrc/routes/projectRates.ts follow this sequence:
- 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. - Acquire the vocabulary lock —
lockPricingRuleVocabularytakes the org-scoped advisory lockstralos:pricing-rules:rekey:<organisationId>. This is the same lock every rate option mutation takes. - Write rows, enqueue one re-price row, and enqueue one
ProjectRatesChangedevent in the same transaction. The option write, the re-price outbox insert and theevent_outboxinsert commit atomically. - Deliver pending re-prices after commit:
repriceAfterWritecallsdeliverPendingReprices, 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,publishAfterCommitpublishes the enqueued event.
Re-price outbox
Migration 0039 addedrates_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_URLfrom 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 returnskind: "unavailable".
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 retainprojectRateOverrideId; 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.
project_id,rate_item_id,cost_rate = override_final_rate,notes.
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 afixed 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 onlyrates_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 atiered 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 astepped 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
costRatedifferent from the stored one is refused with422 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
costRatesupersedes the table. The handler removes the option’smatrixbase rule in the same transaction, under the pricing-rule vocabulary lock, so the deferredrates_rate_options_stepped_cost_checkfinds a costed option with no must-pick base at commit. An update withoutcostRatekeeps the table and the null cost.
Slot uniqueness and conflict codes
The database keyrates_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 throughPOST /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 addedrates_project_rate_versions, a per-project counter. Each event bumps it withINSERT … 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 oneventIdonly and never drop an event by version.- Outbox and publish: the envelope is written to
event_outboxin the write’s transaction. After commit,publishAfterCommitcallspublishNow, which sends it to EventBridge throughcreateInlineEventBridgeClientwith a 3-second abort timeout (INLINE_PUBLISH_TIMEOUT_MS). An event that is not sent in time stays inevent_outbox. - Flush Lambda:
src/event-flush-lambda.tsruns on arate(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 nullableestimate_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 gainCOALESCE(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_uniquerates_rate_options_one_currentrates_project_rate_rules_org_project_scope_live_uniquerates_project_rate_settings_org_project_unique
(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_idis null (library promotion), it clearsestimate_id. - If
estimate_idis null butproject_idis set, it readsrates_project_default_estimatesfor the project and stamps that estimate. - Rows that already carry an
estimate_idare unchanged.
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 becauseestimate_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/backfillrecords 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/defaultsmoves the default to a named estimate. api-estimate calls this after everymake-primarycommit and after anawardcommit, so the default follows the primary. The call is best-effort — a failure is logged and never fails the request. A 10-minute sweep (sweepRatesDefaultsinsrc/estimates/ratesDefaultSync.ts) re-sends the primary of every project whose primary row was written within the last 26 hours, retrying any lost push. ThePUTis idempotent and api-rates’ response isunchangedwhen nothing moves.
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 anestimate_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:
- An explicit
estimateIdfrom the caller is used as-is (lower-cased). - When absent, the project’s recorded default estimate from
rates_project_default_estimatesis used. - When absent and no default is recorded,
GET /api/estimate/projects/:id/estimateis called on the caller’s behalf (15-second timeout). A404or409from api-estimate returns409 estimate_requiredto the caller. The fetched id is recorded as the default — first writer wins.
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:
- 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 stampeddeleted_at. - 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. - 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).
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 anestimate-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’sProjectRatesChanged 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.
