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

# Tender check

> Internal architecture for the tender check AI review across api-estimate, api-documents and feature-estimate

Tender check gives an estimator an AI-assisted review of a tender estimate before it is sent. The UI calls it **Tender review**; code, routes, tables and this page keep the `tenderCheck` name. It spans three repositories:

* `stralos-feature-estimate` renders **Tender submission** → **Tender review** (`src/tenderCheck/`).
* `stralos-api-estimate` owns runs, findings, caps, the estimate snapshot and the rule signals (`src/tenderCheck/`).
* `stralos-api-documents` runs the asynchronous recommend and review jobs (`src/tenderCheck/`, `src/routes/tenderChecks.ts`, `src/pipeline/handlers/tenderCheckJobs.ts`).

The office price comparison also reads `stralos-api-global` through a service token, and the review streams from `stralos-api-assistant`'s streaming structured completion function.

For the estimator-facing workflow, see [Tender review](https://help.stralos.app/guides/estimators/tender-check.md). [Schedule check](https://help.stralos.app/internal/schedule-check.md) (shown as **Schedule review**) runs on the same run lifecycle, tables and jobs.

## Access and switches

The `estimate-tender-check` PostHog flag is the only switch. `EstimatePage` reads it with `fallback: false`, so an unknown or unreachable PostHog hides the section, and a direct URL to the `tender-check` section is sent back to the default Tender submission section. Neither API has a server-side switch; the caps below are the cost guard.

Anyone who can see the estimate can run a check; there is no admin check. Every api-estimate route reads through `runScoped`, so RLS (tenant isolation and project membership) turns a foreign estimate, or one outside a contractor's projects, into a `404`. Checks run on tender estimates only; any other estimate kind is refused with `409 tender_check_tender_only`. A new check starts only on an editable tender: start, confirm and `start-now` answer `409 tender_check_frozen` once the tender has been submitted, won or not won (`src/tenderCheck/startGate.ts`), with a message naming that state. Reads, finding status changes, and citation downloads are never blocked. Past checks stay readable, and the panel shows a submitted tender's history without **Start a new check**.

## Review memory

`src/tenderCheck/reviewMemory.ts` in feature-estimate remembers where the estimator was in a review, for both the Tender review panel and the Schedule review sheet, so coming back lands in the same place. Per review kind and estimate it keeps the run on screen (`stralos.tenderReview:<estimateId>` or `stralos.scheduleReview:<estimateId>`) and, per run, the three filters, the scroll position and the finding an item was opened from. On the way back the panel scrolls to that finding, highlights it for 2 seconds and clears the stored id.

Values live in an in-memory `Map` mirrored to `sessionStorage`, so they survive moving between estimate pages in the same tab but not across tabs. Every storage access is guarded: blocked or full storage keeps the value in memory only, and a value that does not parse is ignored.

Memory belongs to one project, noted under `stralos.reviewMemory.project` as `{ projectId, seq }`. `noteReviewProject` forgets every remembered review of both kinds when:

* a different project is noted, or
* the shell's project visit counter (`stralos.shell.projectVisit.v1` in `sessionStorage`, whose `seq` goes up each time the tab moves to a different project) has moved on for the same project. This catches a typed URL, bookmark or reload into another project while the estimate remote was not loaded.

An older shell never writes the counter, and a note written before it existed is a bare project id; both fall back to comparing the project id only. Each review calls `useReviewProject` as it renders, and the first one also subscribes once per tab to the shell's `__STRALOS_NAV__` bridge (version 1), so visiting another project's pages resets the memory even when no review is on screen. Another estimate in the same project keeps its own memory.

## Run lifecycle

A run moves through these statuses in `estimate_tender_checks`:

| Status | Meaning |
| - | - |
| `recommending` | Draft. The documents recommend job is picking candidate documents. |
| `choosing` | Draft. The estimator is choosing documents. |
| `preparing` | Confirmed. The review job is waiting for unread documents to be read or downloaded. |
| `queued`, `running` | Confirmed. The review job is running. |
| `complete`, `failed`, `cancelled` | Terminal. Every run is kept as history. |

Confirming a run freezes its inputs: the selected documents, candidates, used documents, run number and `confirmed_*` columns are each written by one guarded update whose `WHERE` clause carries the status precondition. Findings are stored in `estimate_tender_check_findings`; their content columns are never updated.

Each finding has a working `status`: `to_do` (the default), `looking_into_it`, `fixed`, `accepted` or `not_relevant`. `not_relevant` replaces the old dismissal and hides the finding in the UI. Setting it also writes `dismissed_at` and `dismissed_by`; moving away from it clears them and writes `restored_at` and `restored_by`. `status_changed_at` and `status_changed_by` record the latest change only. `dismissed_at` remains the hidden marker (`findingStatusOf`): a dismissed row always reads `not_relevant`, so a release that still dismisses and restores without naming `status` stays consistent. Findings belong to one run, so a new run starts every finding at `to_do`.

Run finding counts carry `high`, `medium` and `low` for findings still shown, `dismissed` for `not_relevant` findings, and a `byStatus` count for each status.

The poll is persist-on-observe, like the variation assessment: the documents job store keeps the result, and whichever poll observes it first persists it once under the status guard.

`estimateChangedSinceCheck` on a run is true when the estimate or its estimate data (work items, their components and related rows) has been created, updated or deleted since `confirmedAt` (`estimateChangedSince` in `routes.ts`). The UI shows a "changed since this check" notice from it.

Every run DTO carries `serverTime`, the server's clock as an ISO string. The UI uses it to correct for browser clock skew in the **Time so far** timer.

The poll also passes through `live`, the documents job's live view (see [Live progress](#live-progress)), for runs in `preparing`, `queued`, `running` or `failed`. `failed` is included so the provisional findings stay visible, marked "Not confirmed". api-estimate never stores `live`. It parses the view loosely (`src/tenderCheck/live.ts`) so either service can deploy first: unknown keys are kept, and a shape that still fails returns `live: null` with a log line, never a `502`.

## api-estimate endpoints

All routes sit under the `/api/estimate` prefix.

| Method and path | Purpose |
| - | - |
| `GET /estimates/:estimateId/tender-checks` | The current draft, the active run and up to 50 confirmed runs, newest first. Each run carries `requestedByName`, resolved from api-global (`null` when it cannot be resolved). |
| `POST /estimates/:estimateId/tender-checks` | Start a draft and its recommend job. Returns the existing draft or active run if there is one. |
| `GET /estimates/:estimateId/tender-checks/:runId` | Poll one run, including findings, coverage and `requestedByName`. Findings are mended on read; see [Finding text](#finding-text-and-completion). |
| `POST /estimates/:estimateId/tender-checks/:runId/confirm` | Confirm the selected documents, build the snapshot and start the review job. |
| `POST /estimates/:estimateId/tender-checks/:runId/start-now` | Stop waiting for unread documents. |
| `POST /estimates/:estimateId/tender-checks/:runId/cancel` | Cancel a draft or a run that is still preparing. |
| `PATCH /estimates/:estimateId/tender-checks/:runId/findings/:findingId` | Set a finding's `status`. Setting the status it already has returns the finding unchanged. An unknown status is `422 invalid_finding_status`. |
| `POST /estimates/:estimateId/tender-checks/:runId/findings/:findingId/dismiss` | Alias for `not_relevant`, with an optional `reason` (up to 500 characters). Kept for the previous frontend during a rollout. |
| `POST /estimates/:estimateId/tender-checks/:runId/findings/:findingId/restore` | Alias that moves a `not_relevant` finding back to `to_do`. Any other status is left as it is. |

### Caps

Constants live in `src/tenderCheck/constants.ts`.

| Cap | Value | Error code |
| - | - | - |
| Confirmed checks per company, rolling 24 hours | 50, shared with Schedule check | `tender_check_daily_cap` |
| Started drafts per company, rolling 24 hours | 150, shared with Schedule check | `tender_check_draft_cap` |
| Time between confirmed checks on one estimate | 60 seconds | `tender_check_too_soon` |
| Active checks per estimate | 1 (advisory lock and partial unique index) | |
| Documents per check | 60 | `too_many_documents` |
| Build-up lines in the estimate | 10,000 | `estimate_too_large` |

The two company counts run on the BYPASSRLS platform pool, because project-scoped RLS would under-count for a contractor. Neither count filters `check_kind`, so Tender checks and Schedule checks share both buckets; every other read and write is per kind. Each error code maps to fixed user copy in `ERRORS`; the daily cap's copy (`SHARED_DAILY_CAP_MESSAGE`) names both reviews.

### Candidates and purposes

Candidates are grouped by the UI into **Used in this estimate** (any `usedPurposes`), **Recommended** and **Other documents**. The purposes and their chip labels are:

| Purpose | Chip |
| - | - |
| `takeoff_measurement` | Takeoff |
| `work_item_source` | Estimate item source |
| `bid_rate_source` | Subcontractor quote |
| `package_attachment` | Subcontract package |
| `rate_attachment` | Rate attachment |
| `variation_contract` | Variation contract |
| `contract_set` | Contract documents |
| `supplier_quote` | Supplier quote |

`contract_set` and `supplier_quote` are computed estimate-side. `supplier_quote` marks the source of a project rate that a live line is priced from: an accepted supplier quote kept as a project document, or a Rates Library import file.

### Rates Library import files

When a live line is priced from an option that a Rates Library import wrote, api-estimate offers the import file as an estimate-side candidate (`docKind: "rate_import"`, with the import file's id, not a document id), ticked under **Supplier quote**. An import already kept as a project document is not offered twice. On confirm, api-estimate reads the file's stored parsed text from api-rates with the caller's token and sends it inline as `importFiles` (at most 20 files and 300,000 characters). Nothing is copied into the project's documents. Every read degrades: a failure adds a coverage note rather than failing the check. Citations from these files have `kind: "rate_import"` and the UI shows them as text, not as an openable document.

### Own pricing documents

Each candidate carries `ownPricing`, from `isOwnPricingDocument` in `src/exports/tenderDownloadOptions.ts` (the rule the Tender download's default ticks use): a folder segment naming pricing, prices, quotes, quotations or submissions, or a file name or title containing the word "submit". An own pricing document the estimate does not use is never recommended and stays under **Other documents**. One the estimate uses keeps its `usedPurposes` and stays ticked. Older stored candidates get the flag on read (`src/tenderCheck/ownPricing.ts`).

### Finding text and completion

Findings are mended on read and the stored rows are never rewritten, so past runs mend too (`src/tenderCheck/findingText.ts`). A clause id the model wrote (a whole id or its 8-character prefix) becomes the clause's quoted label, an ISO date becomes a date such as `6 Oct 2026`, and `workItemIds` are sorted by item number in natural order (1.2 before 1.10). Each finding also carries `clauses` (label and number in its group) and `items` (display number).

New runs only, before the findings are inserted (`src/tenderCheck/findingCompletion.ts`):

* AI findings of the same check type and severity that cover the same set of two or more items are merged: the first finding's title and position are kept, details and suggested actions are joined, and clauses and citations are unioned. Single-item findings and rule findings are never merged.
* Each concern the overview names (for example "may not be priced", "missing", or "no allowance") that no finding covers becomes a finding of its own.

### Snapshot and rule signals

On confirm, api-estimate builds an immutable snapshot of the estimate (`src/tenderCheck/snapshot.ts`) with a 150,000-character JSON budget. It includes the estimate header (project name, site address, reference, job number, pricing basis), sections, items, build-up lines, clauses, packages and takeoff totals. It also carries:

* `supplierTerms`: up to 40 rows for the quotes the estimate's lines are priced from: project rates and subcontract bids that have notes or an expiry. Bids are grouped per package, expiry and notes. Each row has the supplier, quote reference, `validUntil`, notes (cut to 300 characters) and up to five linked work items. Lead times and payment terms exist only in these free-text notes.
* `keyDates`: the check date, target submission date, and project and programme start and end dates.

Both peer reads degrade to an empty section.

The rule signals (`src/tenderCheck/signals.ts`) are computed from the full estimate before the snapshot is compacted, so the budget never hides an anomaly. api-documents turns each signal into a `rule` finding:

| Signal key | Check type | Severity |
| - | - | - |
| `zero_price_item` | `pricing` | high |
| `no_build_up` | `pricing` | high |
| `zero_rate_line` | `pricing` | medium |
| `negative_margin` | `pricing` | high |
| `margin_outlier` (20 points or more from the estimate default) | `pricing` | medium |
| `provisional_sum` | `pricing` | low |
| `quantity_mismatch` (more than 10% from the takeoff total; one finding per item, at most 30) | `quantity` | medium |
| `linked_quantity_differs` | `quantity` | low |
| `unlinked_measurement` | `quantity` | medium |

## Office price comparison

At confirm, api-estimate compares the estimate with the same office's recent tenders and freezes the result in the snapshot as `officeHistory` (`OfficeHistoryV1`). The gather is in `src/tenderCheck/officeHistory.ts`; the comparison maths is in `src/tenderCheck/officeCompare.ts`.

### Isolation

The comparison is independent of the person running the check: it reads every tender from the office, including projects the person cannot open. It never leaves the organisation:

1. api-estimate mints an `estimate-office-history` service token for the estimate's organisation and calls `GET /api/global/service/organisations/:orgId/projects/:projectId/office-projects` for the office's other projects. See [Service token authentication](https://help.stralos.app/internal/service-token-auth.md#service-only-endpoints).
2. Prices are read on the runtime pool under `withOrganisationProjectsTenant`: the tenant GUC plus `app.project_ids` pinned to exactly those projects. The person's scope and the BYPASSRLS platform pool are not used.
3. Every query also names the organisation. Only live tender estimates' committed revisions are read, and item extraction runs in SQL so large snapshots never load into the Lambda.

Any failure, or a gather longer than 4 seconds, degrades to `status: "unavailable"` with a coverage note. It never fails the confirm.

### Tenders and matching

The comparison uses the office's 5 most recently submitted tenders, whatever their outcome, plus its 2 most recent wins. Tenders priced in another currency are left out, with a note.

Current items are matched to past items on the same canonical unit (lump sums are excluded) and compared on unit cost and per-item margin:

| Match | How |
| - | - |
| Exact | The same library activity or rate item, computed by api-estimate. |
| Similar | AI pairing of clearly similar work by api-documents (see [Office pairing](#office-pairing)), re-validated by api-estimate against the refs and units it sent. |

A past item with a quantity more than 10 times larger or smaller than the current item's is treated as a different job size and skipped. An item needs at least 2 past points. It is an outlier when it sits outside the past range and its unit cost is at least 25% from the median, or its margin at least 5 percentage points from the median. The overall, contingency and shared costs figures are flagged when outside the range and at least 5, 3 and 5 points from the median respectively.

### Findings and the run DTO

When the poll persists a complete run, `buildOfficeOutcome` builds the office findings from the frozen inputs and the documents result, so every poll computes the same result. It writes at most 20 item findings (medium and high severity only; low outliers appear only in the list) and at most one overall margin finding. All are `source: "rule"`, `checkType: "pricing"`:

| `ruleKey` | Flags |
| - | - |
| `office_price_outlier` | Unit cost against exact-matched past items. |
| `office_price_similar` | Unit cost against AI-paired past items. Severity is capped at medium. |
| `office_item_margin` | Per-item margin. |
| `office_margin` | Overall, contingency or shared costs figure. |

Past tender evidence is stored as `kind: "past_tender"` citations but is never sent in the DTO's `citations`, because an older frontend fails on an unknown citation kind. It is returned in the finding's `pastTenders` instead. The run DTO's `officeComparison` (stored in `summary.officeComparison`) carries the status, office name, tenders, margin block and outlier list. It is `null` on runs completed before the comparison existed.

| `status` | Meaning |
| - | - |
| `compared` | The comparison ran. Only this status shows the UI section. |
| `none_found` | The office had no eligible recent tenders. |
| `no_office` | The project has no office. |
| `unavailable` | The gather failed or timed out. |

## api-documents jobs

api-estimate is the only caller. It forwards the user's token; the job's organisation is bound from that token, never the body, and writes run through `runScoped`. All routes sit under `/api/documents`.

| Method and path | Purpose | Rate limit |
| - | - | - |
| `POST /tender-check-recommendations` | Enqueue a recommend job. | 30 per minute |
| `POST /tender-check-reviews` | Enqueue a review job. | 10 per minute |
| `GET /tender-check-jobs/:id` | Poll a job. | |
| `POST /tender-check-jobs/:id/start-now` | Stamp `start_now_at` on a `queued` or `preparing` job and republish it. Otherwise `409`. | |
| `POST /tender-check-jobs/:id/cancel` | Fail a `queued` or `preparing` job with `error_code: cancelled`. Otherwise `409`. | |

Both POSTs check that the estimate belongs to the named project, write a `documents_tender_check_jobs` row and a `documents.tender-check.requested` outbox event in one transaction, and return `202` with a `jobId`. The review route also refuses a snapshot over 200,000 JSON characters (`422`), any document id outside the project (`422`), and a 56th review job for the organisation in 24 hours (`429`). That 55-job backstop is reachable only by bypassing api-estimate's cap of 50.

Jobs run on the dedicated `tender-check-jobs` SQS queue.

The job GET returns `live` while the review runs (see [Live progress](#live-progress)). On a complete job whose `snapshot.officeHistory.status` was `"compared"`, the result also carries `officePairs` and `officePairing` (see [Office pairing](#office-pairing)).

### Recommend job

The recommend job reads up to 500 project documents, works out each one's readiness, and asks the shared model which documents are worth checking. It returns at most 40 recommendations, each with a reason of up to 160 characters, and drops any for a document already used or not in the candidate list. A model failure still completes the job with `recommendationsAvailable: false`; the UI then ticks only the used documents and says so.

### Readiness

`src/tenderCheck/readiness.ts` decides each document's readiness:

| Readiness | When |
| - | - |
| `ready` | Extracted text exists. |
| `needs_reading` | A clean, stored, extractable file (PDF, TIFF, image, DOCX, XLSX or CSV) with no text yet, or a drive-linked file that passes the link-first fetch policy. |
| `processing` | A text-extraction job is queued or running, the normal pipeline is processing the document, or a drawing is converting. |
| `cannot_read` | Deleted, infected or rejected, store-only (for example emails and schedules), failed extraction or conversion, or a linked file that cannot be fetched. The row carries a plain-language reason. |

### Preparing

A confirmed review starts in `preparing` while any selected document is not `ready`:

* For a `needs_reading` stored document, the worker queues a text-extraction-only job (`documents_text_extraction_jobs`, one live job per document) straight onto `parse-jobs`. It writes extracted text without changing the document's status, routing it, or generating suggestions.
* For a selected drive-linked document, the worker requests a drive download under the review's project scope. The file is scanned as usual, and the next pass requests text extraction from the stored copy. Downloads are refused while drive sync is paused.
* The worker records per-document `progress` and sends itself a 30-second delayed re-check on its own queue. Only the delivery holding the current `next_check_at` token sends the next one.

Preparing ends when every document is ready or unreadable, when `start-now` is called, or after the 20-minute prepare deadline. Documents still not ready are reported as `not_ready`.

### Review run

1. **Claim.** An atomic update moves the job to `running`. `started_at` is the lease: a duplicate delivery matches no rows, a `running` job older than 20 minutes can be taken over, and only the current lease holder can write the terminal status.
2. **Shard.** Up to 30 readable documents form one shard; 31 to 60 split into two shards balanced by text length. `contract_set` documents go into both shards.
3. **Retrieve.** Documents are cut into 1,500-character page-aware windows with 200 characters of overlap and scored against the snapshot and a contract-risk lexicon. Each shard has a 300,000-character budget. Documents under 12,000 characters go in whole. Summaries (up to 800 characters each, 20,000 in total) go first.
4. **Prompt.** One structured completion per shard, in parallel, on the `tender-check` model profile (see [Model profiles](https://help.stralos.app/internal/assistant-service.md#model-profiles)). The system prompt carries contract-form guidance for the request's `contractForm`, with clause hints for recognised forms. Untrusted text (estimate fields, document text, file names) sits inside a per-request nonce fence. When `officeHistory.status` is `"compared"`, the prompt adds a fenced `## Recent tenders from this office` section (up to 6,000 characters), lists the exact price outliers under `## Already found`, and tells the model the office data is context only. The office pairing call runs in the same `allSettled` as the shards.
5. **Validate citations.** A citation must name a window supplied to that shard and quote at least 10 characters that appear verbatim in it. Work item and clause ids must be the snapshot's. A finding that loses its grounding is dropped and counted.
6. **Merge.** Shard findings merge (at most 120 per shard and 150 per check), rule findings are added, and the result is ordered by check type then severity.

A shard failure is contained: its documents are reported as `not_checked` and the check completes on the other shard's findings. A shard gets one schema-repair retry when enough of the 840-second worker budget remains.

### Streaming

When `STRUCTURED_COMPLETION_STREAM_FUNCTION_ARN` is set, each shard calls api-assistant's [streaming structured completion function](https://help.stralos.app/internal/assistant-service.md#streaming-function) with `InvokeWithResponseStream` (`src/providers/ai/assistantStream.ts`), with a 410-second overall timeout and a 60-second idle timeout. `thinking` frames feed the live summary and `output` frames are scanned for findings as they arrive (`findingStream.ts`). Only the terminal `result` frame is validated and persisted. If the variable is unset, the worker calls the sync function directly. If the stream fails before its first frame, the worker logs `tender_check_stream_unavailable` and sends the same request to the sync function.

### Live progress

`documents_tender_check_jobs.live` (jsonb) holds a display-only `TenderCheckLiveV1` view of a running review. One writer per job (`liveWriter.ts`) updates it under the claim lease at most every 2.5 seconds, and at once on a stage change. A failed write never fails the check. Persisted findings still come only from the result.

| Field | Contents |
| - | - |
| `v`, `seq`, `updatedAt` | Version `1`, a write counter that continues across a stale-worker takeover, and the write time. |
| `stage`, `stageStartedAt` | `gathering` (the first write, at the claim), `checking`, `verifying` or `done`. `reading` is defined, but the worker writes its first view at `gathering`; the UI derives step 1 from the run's `preparing` progress. |
| `counts` | `documentsTotal`, `documentsRead`, `documentsUnreadable`, `items`, `clauses`, and `officeTenders` only when the office comparison ran. |
| `calls` | `total` and `finished` shards, and `retrying`. |
| `summary` | The line under the steps (`text`, `source` of `thinking`, `finding` or `fallback`, `at`), or `null`. |
| `findings` | Up to 60 provisional findings, each with `key`, `state` (`checking` or `checked`), `checkType`, `severity`, `title`, `detail` and up to 2 citation labels. |
| `more`, `leftOut` | Findings beyond the 60 shown, and findings withdrawn because their quotes failed validation. |
| `foundBeforeStop` | Failed runs only: the number of provisional findings kept at failure. |

On a complete run the view ends at `done` with no findings, because the results replace it. On a failed run it keeps its last stage and findings, and the UI shows them under "Found before it stopped".

#### Live summary

`liveSummary.ts` turns the model's thinking into a line of at most 140 characters. Raw thinking is never stored or returned.

* Candidates come from the last 4,000 characters of thinking: Markdown headings or bold lines first, otherwise complete sentences. A `.`, `!` or `?` ends a sentence only when followed by whitespace or the end, so `$1,234.58` is not split. An unfinished trailing line is never a candidate.
* A candidate must start with a capital letter, an opening quote or an opening bracket; anything else is a fragment.
* Candidates are rejected for the request nonce, first- or second-person wording, URLs, domains, email addresses, internal terms (such as prompt, schema, JSON, tokens), runs of capitals, or apologies, and must be 12 to 220 characters before the 140-character cap. Window ids become "file, page" labels and NZ spelling is applied.
* While the model writes a finding, the line reads `Writing up: <title>`. With no accepted line, a static fallback says how many documents and items the AI is reading.
* A new line is published at most every 4 seconds.

### Office pairing

When `officeHistory.status` is `"compared"`, a separate `tender-check-office-pairing` completion (`officePairing.ts`) pairs current items with clearly similar past items on the `tender-check` model profile. It has its own 120-second abort, one schema-repair retry and a 60 KiB prompt cap, and never fails the job. A malformed `officeHistory` is never a `400`: the worker treats it as absent.

The complete result carries `officePairs` and `officePairing`, whose `status` is `complete`, `skipped` (nothing to pair) or `failed`. Both are absent when the comparison did not run. An older api-documents never sets them, so api-estimate compares exact matches only; a failed pairing adds the "only exact matches were compared" note.

### Check types and grounding

| Check type | Covers | Grounding needed to keep an AI finding |
| - | - | - |
| `scope_gap` | Work the documents require that the estimate doesn't price, or excludes without a matching qualification. Also one low finding per document that appears to be for a different project (by title block, project name, site address or job number). | At least one valid document citation. |
| `pricing` | Anomalies beyond the rule findings. | At least one valid work item id. |
| `clause` | Qualifications that conflict with the documents, and contract risks: supplier lead times against the programme and key dates, quote expiry, payment terms, one-sided clauses, liquidated damages, fixed price without escalation and onerous insurance. | A valid citation, clause id or work item id. Lead-time and payment-term findings come from `supplierTerms`, so work item ids alone are enough. |
| `quantity` | Quantities that disagree with the takeoff or with quantities stated in the documents. | At least one valid work item id. |

A document flagged as belonging to a different project must not be used as evidence for any other finding. Generic documents such as standards and codes of practice are not flagged.

Severity is `high` (likely to cost money or win or lose the job), `medium` (worth checking before sending) or `low` (for information).

The review output caps are stated in the prompt as well as enforced by the schema, because providers strip length keywords from the JSON schema: overview 1,500 characters, 120 findings, titles of 12 words and 160 characters, detail 2,000 characters, suggested action 500 characters, 20 work item ids, 10 clause ids, and 5 citations of up to 600 characters per finding. Each run records `provider`, `model` and `prompt_version` (currently `tender-check-v7`).

The prompt also says how to name things in the title, detail, suggested action and overview, because the estimator reads them:

* Never write an id or part of one (item, clause, section, window or document id). Ids go only in `workItemIds`, `clauseIds` and citations.
* Name an item by its number and name ("item 1.2 Excavate trench"), listing item numbers in number order.
* Name a clause by its reference and first words ("Departures 3 (Traffic management by others)"). The snapshot's clause table carries a `ref` column for this: the clause's group and its one-based position in that group, as the Departures and clarifications list numbers them.
* Name a document by its file name, and write dates as "6 Oct 2026", never ISO.
* Write one finding per issue, and make every concern the overview names a finding too.

Citation `kind` is `document` (a retrieved window), `takeoff` (a rule finding's takeoff reference) or `rate_import` (an inline import file). api-estimate also stores `past_tender` evidence on office findings, returned as `pastTenders` rather than citations.

### Coverage

The coverage report lists each document with one of `searched`, `truncated`, `omitted`, `not_ready`, `not_checked`, `extraction_failed` or `cannot_read`, plus notes (for example when import files or the contract profile could not be read). The UI maps these to the **What we read** labels.

### Failures and backstops

A failed run carries one of these `error_code` values, set by `classifyModelError` in api-documents (`src/tenderCheck/review.ts`, shared with the schedule check) and mapped to user copy by api-estimate (`FAILED_RUN_COPY`). feature-estimate keeps a matching table for a run that arrives without a message:

| Code | Meaning |
| - | - |
| `start_failed` | The job could not be started. |
| `model_unavailable` | The AI provider was busy or unavailable, including a retryable 408, 409, 425 or 429. A retry may succeed. |
| `model_rejected` | The AI provider refused the request itself, with any other 4xx status (for example an Anthropic 400 for a schema grammar it can't compile). The same request fails the same way, so the copy asks the person to try later and report it if it keeps happening. |
| `timed_out` | The run took too long. |
| `invalid_output` | The model output failed validation after the retry. |
| `request_refused` | The structured completion function refused the request as one the caller must change (`StructuredCompletionRequestError`), or the prompt was over the size cap even with no documents. |
| `worker_failed` | Any other worker failure. Unknown codes read as this. |
| `cancelled` | The run was cancelled. |

The scheduled sweep republishes a `preparing` job whose re-check is more than 5 minutes overdue, and fails any job still `queued`, `preparing` or `running` after 90 minutes with `timed_out`, so api-estimate's poll always ends.

## Local development

Set `AI_PROVIDER=fixture` in api-documents to use the in-process deterministic provider. It builds findings from the real retrieval windows at run time, because citations must reference server-issued window ids.


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