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

# Project rates service

> Implementation details for project-scoped rate options in stralos-api-rates (Project Rates redesign S6b, PRD 210 phase 2)

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](https://help.stralos.app/api/rates.md) 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:

| Column | Migration | Purpose |
| - | - | - |
| `mirrors_option_id` | 0040 | Origin library option id for a converted project price. Retained for companion identity and migration history; it does not reference the retired override table. |
| `estimate_id` | 0056 | The estimate this row belongs to. Null until backfilled; stamped automatically by the `rates_stamp_estimate_scope` trigger on subsequent writes. See [Estimate-scoped project rates](#estimate-scoped-project-rates). |
| `source_position`, `source_import_id` | 0066 | The supplier quote's own line order. `source_position` is the option's 1-based place in the import commit's `rates[]`; `source_import_id` is one UUID per import commit, and positions compare only within one. Both or neither (`rates_rate_options_source_position_check`, position at least 1); null on options not written by an import, with no backfill. An import that updates an existing option re-stamps both. Feature-estimate's **Quote order** sort reads them. |

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](https://help.stralos.app/internal/rates-service.md#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:

| Column | Description |
| - | - |
| `project_id` | The project to re-price. |
| `rate_item_id` | The item whose price changed. |
| `rate_option_id` | The specific option that changed. |
| `change` | `created`, `updated`, or `deleted`. |
| `delivered_at` | Null until successfully delivered. |
| `attempts` | Count of delivery attempts, successful or not. |
| `last_error` | The failure reason, `skipped: <code>` for a terminal skip, or null after normal delivery. |
| `created_by` | UUID of the user behind the write, or null for a service token. |
| `event_id` | The `ProjectRatesChanged` event that carries this row (migration 0051). Null for rows written before that migration. |

`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

| api-estimate response | Outcome |
| - | - |
| `200` | Row marked delivered. |
| `404` code `no_estimate` | Row marked delivered, `last_error: skipped: no_estimate`. No estimate exists for this project. |
| `409` code `estimate_frozen` | Row marked delivered, `last_error: skipped: estimate_frozen`. The estimate is submitted, accepted, rejected, or archived; nothing is held for later. The response message names the actual state (for example, "Estimate archived — its pricing is frozen. Unarchive the project to edit it.") and the `status` field carries the state token. |
| `422` code `no_project_rate_changes` | Row marked delivered, `last_error: skipped: no_project_rate_changes`. Unexpected — the row names a project option, so this outcome is also logged. |
| Any other error (5xx, timeout, unreachable, unreadable 200) | Row stays pending, `attempts` incremented, `last_error` records reason. |

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](https://help.stralos.app/internal/estimate-service#projectrateschanged-event-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](#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:

| Outcome | Meaning |
| - | - |
| `recorded` | No default existed; one was recorded. |
| `moved` | The default was updated to the new primary. |
| `unchanged` | The named estimate was already the default. |
| `estimate_discarded` | The named estimate is the target of an abandoned copy job; the existing default is kept and an error is logged. |

**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](#settings-soft-delete-migration-0058)).

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

| Field | Type | Description |
| - | - | - |
| `sourceEstimateId` | UUID | The saved version whose project rates are restored. Only ever read. |
| `targetEstimateId` | UUID | The working estimate whose project rates are replaced. Must differ from `sourceEstimateId`. |
| `projectId` | UUID | The project both estimates belong to. |
| `correlationId` | string (1–128 chars) | api-estimate's restore job id. Replaying with the same value returns the same id maps and writes nothing. |

**Response shape:**

| Field | Type | Description |
| - | - | - |
| `sourceEstimateId` | UUID | Echo of the request. |
| `targetEstimateId` | UUID | Echo of the request. |
| `projectId` | UUID | Echo of the request. |
| `correlationId` | string | Echo of the request. |
| `replayed` | boolean | `true` when this was a replay: nothing was written. |
| `rateItemIdMap` | object | Source project-only rate item id → its new copy id on the target. |
| `rateOptionIdMap` | object | Source project rate option id → its new copy id on the target (retired options included). |
| `ruleIdMap` | object | Source live project rate rule id → its new copy id on the target. |

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

| Property | Value |
| - | - |
| Caller name | `estimate-rates` |
| Subject | `service:estimate-rates` |
| Scope | `rates:estimate-rates` |
| Mode | Tenant — the organisation is in the token; no `x-forwarded-user` |
| Audience | `stralos-api-rates` only |

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

| Method | Route |
| - | - |
| `GET` | `/api/rates/project-rates` |
| `GET` | `/api/rates/project-rate-settings` |
| `GET` | `/api/rates/rate-items/:id/options` |
| `GET` | `/api/rates/rate-items/:id` |
| `GET` | `/api/rates/rate-options/:id/companions` |
| `POST` | `/api/rates/rate-items/lookup` |

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

| Action | Role |
| - | - |
| Read project rates | Authenticated organisation member, or the `estimate-reprice` service token for the five reads above |
| Create, update, delete project rates | Authenticated organisation member |
| Promote a project-only rate to the library | Authenticated organisation member |
| `POST /estimate-rates/backfill` | `estimate-rates` service login only |
| `PUT /estimate-rates/defaults` | `estimate-rates` service login only |
| `POST /estimate-rates/copy` | `estimate-rates` service login only |
| `POST /estimate-rates/restore` | `estimate-rates` service login only |
| `POST /estimate-rates/discard` | `estimate-rates` service login only |

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](https://help.stralos.app/internal/org-security.md#rates-project-membership-access-control).


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