scheduleCheck name. It reuses the Tender check run lifecycle, tables and jobs:
stralos-feature-estimaterenders 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.stralos-api-estimateowns runs, findings, the schedule snapshot, rule signals, suggested fixes and Apply (src/scheduleCheck/).stralos-api-documentsruns the recommend and review jobs on the Tender check endpoints and worker (src/scheduleCheck/).stralos-api-assistantreads native PDFs for it through the structured completion function’s document input.
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 throughrunScoped, 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
applyBlockedis{ code: "estimate_frozen", reason }on a read-only estimate, and the Apply route refuses withestimate_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:
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.
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:
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 carriescheckKind: "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
SetAI_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.
