tenderCheck name. It spans three repositories:
stralos-feature-estimaterenders Tender submission → Tender review (src/tenderCheck/).stralos-api-estimateowns runs, findings, caps, the estimate snapshot and the rule signals (src/tenderCheck/).stralos-api-documentsruns the asynchronous recommend and review jobs (src/tenderCheck/,src/routes/tenderChecks.ts,src/pipeline/handlers/tenderCheckJobs.ts).
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. Schedule check (shown as Schedule review) runs on the same run lifecycle, tables and jobs.
Access and switches
Theestimate-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.v1insessionStorage, whoseseqgoes 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.
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 inestimate_tender_checks:
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), 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.
Caps
Constants live insrc/tenderCheck/constants.ts.
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 (anyusedPurposes), Recommended and Other documents. The purposes and their chip labels are:
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 carriesownPricing, 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.
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:
Office price comparison
At confirm, api-estimate compares the estimate with the same office’s recent tenders and freezes the result in the snapshot asofficeHistory (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:- api-estimate mints an
estimate-office-historyservice token for the estimate’s organisation and callsGET /api/global/service/organisations/:orgId/projects/:projectId/office-projectsfor the office’s other projects. See Service token authentication. - Prices are read on the runtime pool under
withOrganisationProjectsTenant: the tenant GUC plusapp.project_idspinned to exactly those projects. The person’s scope and the BYPASSRLS platform pool are not used. - 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.
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:
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":
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.
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 throughrunScoped. All routes sit under /api/documents.
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). On a complete job whose snapshot.officeHistory.status was "compared", the result also carries officePairs and officePairing (see 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 withrecommendationsAvailable: false; the UI then ticks only the used documents and says so.
Readiness
src/tenderCheck/readiness.ts decides each document’s readiness:
Preparing
A confirmed review starts inpreparing while any selected document is not ready:
- For a
needs_readingstored document, the worker queues a text-extraction-only job (documents_text_extraction_jobs, one live job per document) straight ontoparse-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
progressand sends itself a 30-second delayed re-check on its own queue. Only the delivery holding the currentnext_check_attoken sends the next one.
start-now is called, or after the 20-minute prepare deadline. Documents still not ready are reported as not_ready.
Review run
- Claim. An atomic update moves the job to
running.started_atis the lease: a duplicate delivery matches no rows, arunningjob older than 20 minutes can be taken over, and only the current lease holder can write the terminal status. - Shard. Up to 30 readable documents form one shard; 31 to 60 split into two shards balanced by text length.
contract_setdocuments go into both shards. - 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.
- Prompt. One structured completion per shard, in parallel, on the
tender-checkmodel profile (see Model profiles). The system prompt carries contract-form guidance for the request’scontractForm, with clause hints for recognised forms. Untrusted text (estimate fields, document text, file names) sits inside a per-request nonce fence. WhenofficeHistory.statusis"compared", the prompt adds a fenced## Recent tenders from this officesection (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 sameallSettledas the shards. - 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.
- 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.
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
WhenSTRUCTURED_COMPLETION_STREAM_FUNCTION_ARN is set, each shard calls api-assistant’s streaming structured completion 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.
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.58is 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
WhenofficeHistory.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
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,clauseIdsand 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
refcolumn 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.
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 ofsearched, 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 theseerror_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:
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
SetAI_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.
