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

# Schedule check

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

Schedule check gives an estimator an AI-assisted review of a tender's estimate items against the takeoff, calculators, project scope and contract, with suggested fixes that can be applied to the schedule. The UI calls it **Schedule review**; code, routes and tables keep the `scheduleCheck` name. It reuses the [Tender check](https://help.stralos.app/internal/tender-check.md) run lifecycle, tables and jobs:

* `stralos-feature-estimate` renders the **Schedule review** button and side sheet on the estimate schedule (`src/scheduleCheck/`), reusing the Tender review's chooser, live progress, filters, history and [review memory](https://help.stralos.app/internal/tender-check.md#review-memory).
* `stralos-api-estimate` owns runs, findings, the schedule snapshot, rule signals, suggested fixes and Apply (`src/scheduleCheck/`).
* `stralos-api-documents` runs the recommend and review jobs on the Tender check endpoints and worker (`src/scheduleCheck/`).
* `stralos-api-assistant` reads native PDFs for it through the structured completion function's [document input](https://help.stralos.app/internal/assistant-service.md#document-input).

For the estimator-facing workflow, see [Schedule review](https://help.stralos.app/guides/estimators/schedule-review.md).

## Access and gates

There is no feature flag on either side: the button shows for everyone who can see the estimate's schedule. Every api-estimate route reads through `runScoped`, so RLS turns a foreign estimate, or one outside a contractor's projects, into a `404`.

A review runs only on a tender estimate that has not been won:

* A Delivery estimate or a saved version is refused on every route with `409 schedule_check_kind`. The UI turns the button off on a Delivery estimate and on a won tender with "Schedule review is for tenders before they are won."
* A won tender is refused on start and confirm only. Its past reviews stay readable.
* A submitted tender can still run a review and read its findings, but Apply is off: the run DTO's `applyBlocked` is `{ code: "estimate_frozen", reason }` on a read-only estimate, and the Apply route refuses with `estimate_frozen`.

## Runs and endpoints

`src/scheduleCheck/routes.ts` registers the generic check routes a second time (`registerCheckRoutes` in `tenderCheck/routes.ts`) with the schedule kind's config. Runs live in `estimate_tender_checks` and findings in `estimate_tender_check_findings` with `check_kind = 'schedule'`. Every read and write filters on the kind, so run numbering, history and the one-active-run rule are per kind. The two company caps (150 drafts and 50 confirmed reviews a rolling 24 hours) are the exception: they are shared with Tender check. A malformed body is a `422` here rather than Tender check's `400`.

The run row adds `include_build_up` (default false) and `item_bases`. All routes sit under `/api/estimate`:

| Method and path | Purpose |
| - | - |
| `GET /estimates/:estimateId/schedule-checks` | The current draft, the active review and up to 50 earlier reviews, newest first. |
| `POST /estimates/:estimateId/schedule-checks` | Start a draft and its recommend job, or return the one under way. The takeoff drawings, item source documents and the contract set are always offered. |
| `GET /estimates/:estimateId/schedule-checks/:runId` | Poll one review: findings with their `fix` and its `availability`, `applyBlocked`, `includeBuildUp`, and `live` while it runs. |
| `POST /estimates/:estimateId/schedule-checks/:runId/confirm` | Confirm up to 60 documents and `includeBuildUp`, build the snapshot and signals, freeze the item bases and start the review job. |
| `POST /estimates/:estimateId/schedule-checks/:runId/start-now` | Stop waiting for unread documents. |
| `POST /estimates/:estimateId/schedule-checks/:runId/cancel` | Cancel a draft or a review that is still preparing. |
| `PATCH /estimates/:estimateId/schedule-checks/:runId/findings/:findingId` | Set one of the five manual statuses. `applied` is not accepted here (`422`), and an applied finding cannot change (`409 fix_already_applied`). |
| `POST /estimates/:estimateId/schedule-checks/:runId/findings/:findingId/apply` | Apply the finding's suggested fix. The finding becomes `applied`. |

Error codes and copy are in `src/scheduleCheck/constants.ts` (`SCHEDULE_ERRORS`, `APPLY_ERRORS`). A failed run's message starts "This review didn't finish." and uses the shared `FAILED_RUN_COPY`.

## Snapshot and rule signals

`src/scheduleCheck/snapshot.ts` builds a `ScheduleCheckSnapshotV1` with a 150,000-character JSON budget: the estimate header, sections, items, takeoff measurements, calculators, the project scope (up to 20,000 characters) and the contract profile. Build-up lines go in only when `includeBuildUp` is true, at most 15 per item. The used-document purposes are `takeoff_measurement`, `work_item_source` and `variation_contract`; no pricing purposes.

`src/scheduleCheck/signals.ts` computes the rule signals from the full estimate before compaction, so the budget never hides one. A quantity disagrees when it is more than 5% from its source (`SCHEDULE_QUANTITY_FRACTION`). Units are compared through their canonical form and dimension, and two different dimensions are never compared. api-documents turns each signal into a `rule` finding: the per-item keys give one finding per item (at most 30), the rest one grouped finding.

| Signal key | Check type | Severity | Finding |
| - | - | - | - |
| `takeoff_qty_mismatch` | `quantity` | medium | Per item |
| `calc_output_mismatch` | `quantity` | medium | Per item |
| `unit_dimension_mismatch` | `unit_name` | medium | Per item |
| `same_name_unit` | `duplicate` | medium | Per item |
| `linked_quantity_differs` | `quantity` | low | Grouped |
| `link_stale` | `quantity` | high | Grouped |
| `calc_input_stale` | `quantity` | high | Grouped |
| `same_source_twice` | `duplicate` | medium | Grouped |
| `unused_takeoff` | `duplicate` | medium | Grouped |
| `unused_calculator` | `duplicate` | low | Grouped |
| `no_build_up` | `build_up` | low | Grouped, only when `includeBuildUp` |

A signal carries a fix only when the fix would pass the Apply pre-flight as the estimate stands.

## Suggested fixes and Apply

A fix (`SuggestedFixV1`) is one of:

| `op` | Change |
| - | - |
| `set_qty` | Set an item's quantity. |
| `link_qty` | Link an item's quantity to a takeoff measurement or calculator output. |
| `set_unit` | Change an item's unit. |
| `set_name` | Change an item's description. |
| `add_item` | Add a new, unpriced item. |
| `remove_item` | Soft-delete a duplicate item, naming the item to keep (`keepWorkItemId`). |

`src/scheduleCheck/fixes.ts` validates every fix, from a signal or the model, against the frozen snapshot before it is stored. A fix that names an id the estimate does not hold, or would fail the pre-flight, is dropped and the finding keeps `fix: null`. `expectedQuantity` on a link is always computed by api-estimate. The UI never offers Apply on a `build_up` finding; it shows them as advice only.

At confirm, api-estimate freezes a fingerprint of each item a signal names in `item_bases`: name, canonical unit, quantity to 3 decimal places, a link key (the first 16 hex characters of a SHA-256 over the canonical quantity link JSON) and section. Apply compares the live item with this base rather than `updated_at`, because the pricing recompute bumps `updated_at` on items that share costs or section totals.

Apply runs in one transaction under the estimate-totals lock and then the schedule-check lock. It claims the finding first with a guarded update (`fix_applied_at IS NULL`), so two applies at once give one `200` and one `409 fix_already_applied`; any later refusal rolls the claim back. The change runs through the same cores as the schedule's own `PUT`, `POST` and `DELETE /work-items` routes, so every guard on a hand edit applies. Refusals include `estimate_frozen`, `fix_stale` (the item, takeoff or calculator changed since the review), `fix_not_allowed` (an SOQ-locked quantity or a provisional sum) and `fix_unavailable`.

Each fix's `availability` on the poll is `available`, `applied` (with `appliedAt` and `appliedBy`) or `blocked` with a `code` (`estimate_frozen`, `soq_locked`, `provisional_sum` or `removed_by_variation`) and a plain `reason`.

## api-documents jobs

A schedule review uses the Tender check job endpoints, queue, lease, live progress and streaming. The request carries `checkKind: "schedule"`; `checkKindOf` reads anything else as a Tender check, so older bodies parse as before. The review route parses the body with `scheduleCheckReviewRequestSchema`, and the worker dispatches to `src/scheduleCheck/review.ts`. The 55-job daily backstop is shared by both kinds.

The prompt (`src/scheduleCheck/prompt.ts`, currently `schedule-check-v2`) asks for four check types, `missing_item`, `quantity`, `unit_name` and `duplicate`, plus `build_up` when `includeBuildUp` is true. The build-up check never comments on rates or prices. The model output schema is flat: one citation object with a `kind` enum and one fix object with an `op` enum, every key required, with empty values meaning "not used". Anthropic counts each `anyOf` and nullable as a union when it compiles the grammar, and the earlier nested schema was refused with a `400` ("The compiled grammar is too large"). `citations.ts` reads the flat shape back into the per-kind citation and per-op fix, dropping and counting anything missing a field, and the output caps are applied server-side.

Citations can point at a document window or page, a takeoff drawing page, a takeoff measurement, the project scope or a calculator. The UI opens a document page, the drawing, the calculator, or the project scope in Project settings.

### Drawing pages

Takeoff drawing pages go to the model as pictures (`pageImages.ts`): at most 16 per review and 8 per call. Only renders that already exist are sent. A missing render is requested once in the prepare phase and waited for by the same 30-second re-check loop as document text; still missing at the deadline, the page is skipped as `not_rendered`. Each stored 2,400 px PNG is re-encoded as a JPEG at quality 80 on a 2,000 px long edge, stepped down to 1,600 then 1,280 px when over the per-image share of the 4.2 million-character per-call budget.

### Native PDFs

Every selected PDF that is not a takeoff drawing goes to the model as the PDF itself, through a presigned S3 GET URL that lasts 15 minutes (`nativePdf.ts`). Takeoff drawings keep their page pictures and text; XLSX and DOCX keep their text. This happens only when the AI provider is the assistant and api-documents runs against real S3; otherwise every document is read as text.

The budget is per call: 150 pages (at an estimated 4,000 tokens a page), 26 MiB and 20 documents. Contract documents get it first, then the schedule's own source documents, then the rest in the confirmed order. Anything over the budget is read as text with a reason (`not_pdf`, `too_big`, `page_count_unknown`, `provider_unsupported` or `send_failed`), and the coverage notes say so. Presigned URLs are never logged, stored in the result or coverage, or put in the prompt.

## Local development

Set `AI_PROVIDER=fixture` in api-documents to use the in-process deterministic provider, as for Tender check. Native PDFs need the assistant provider and real S3, so a fixture review reads every document as text.


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