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

# Documents API

> Document upload and AI processing API

The Documents API owns the document upload and AI processing pipeline. It is separate from the Estimate API because document processing includes object storage, malware scanning, OCR/parsing, AI providers, queues, retries, area runs, and reviewable suggestions.

Documents are owned by exactly one of a **project** or a **company** (party). Every upload must supply either `projectId` or `partyId` — not both. Company documents carry additional metadata (`title`, `docKind`, `issueDate`, `reviewDate`) and two pinned document kinds (`contract_agreement`, `day_rates`) are reserved for company ownership only.

## Base prefix

```text theme={null}
/api/documents
```

## Upload flow

1. `POST /api/documents` validates file metadata and creates document rows.
2. The response returns presigned upload instructions for accepted files.
3. The browser uploads directly to S3 or LocalStack.
4. Object-created events trigger malware scanning.
5. Estimate-scoped uploads continue through parsing, summarisation, classification,
   and area modules. Project-scoped and company-scoped on-demand uploads stop at `stored`
   immediately after a clean scan, before parsing, until the user routes and processes them.
6. Users review suggestions before any estimate records are created.

## Service version endpoint

| Method | Path | Auth required | Purpose |
| - | - | - | - |
| `GET` | `/version` | No | Return the running service's configured version. |

### `GET /version`

Returns the running service's configured version and deployed commit. This endpoint is unauthenticated and is intended for the frontend coordinator's minimum-version gate.

**Response**

```json theme={null}
{
  "version": "1.2.3",
  "commit": "0123456789abcdef0123456789abcdef01234567"
}
```

* `version` — value from `SERVICE_VERSION`, falling back to `package.json` only when that variable is absent.
* `commit` — the Git commit SHA stamped into the Lambda as `SERVICE_COMMIT` at deploy time. Returns `"unknown"` when `SERVICE_COMMIT` is not set.

The response includes `cache-control: no-store` so the gate never acts on a cached value.

Release coordinators compare the returned value with the manifest's minimum version before promotion. The `commit` field lets operators tie the serving Lambda to an exact source commit, including on dev where no semver is assigned.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Version returned. |

***

## OpenAPI contract endpoint

| Method | Path | Auth required | Purpose |
| - | - | - | - |
| `GET` | `/openapi.json` | No | Return the machine-readable OpenAPI 3.1 contract for the Documents service. |

### `GET /openapi.json`

Returns the OpenAPI 3.1 document describing the descriptor-backed business routes. Health,
version, and OpenAPI infrastructure routes are intentionally excluded. The document is built once
at service boot from the live route descriptors and served as cached bytes — it is byte-stable per
deploy and never recomputed per request.

Served at `/api/documents/openapi.json`. Auth-exempt. Response includes `cache-control: no-store`.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Document returned. |

***

## Document endpoints

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/` | Create one to twenty document upload records and return presigned uploads. Each file must supply exactly one of `projectId` or `partyId`. |
| `GET` | `/?estimateId=` or `/?projectId=` or `/?partyId=` | List documents scoped to exactly one estimate, project, or company. Pass `includeSuperseded=true` to include superseded rows. |
| `GET` | `/documents/status?ids=` | Internal S2S — bulk status lookup for up to 200 document IDs (comma-separated). Used by the Global service to validate a contract corpus before storing it on the contract profile. |
| `GET` | `/:id` | Fetch document detail and area runs. |
| `PATCH` | `/:id` | Update user-editable fields: `relatedFolder`, `title`, `issueDate`, `reviewDate`. Omitting a field preserves its current value; `null` clears it. |
| `GET` | `/:id/text` | Fetch extracted text. |
| `GET` | `/:id/download-url` | Return a presigned download URL only after a clean scan verdict. |
| `DELETE` | `/:id` | Soft-delete a document. The row is stamped with `deletedAt` and removed from all lists, but its file, renders, and pipeline output are kept. A repeat delete is `204` with no change. The estimate-side reference check is server-to-server; returns `502` if the upstream estimate service is unreachable. |
| `POST` | `/:id/route` | Route a project document into an estimate (or unroute with `estimateId: null`), optionally starting AI processing in the same transaction. Returns `422` for a company document (no project). |
| `POST` | `/:id/process` | Start AI processing on a stored, routed document. |
| `POST` | `/:id/retry` | Retry a failed document from the furthest evidenced stage. |
| `POST` | `/:id/areas/:area/run` | Request or rerun an area module for a document with extracted text. |

## Document update endpoint

### `PATCH /:id`

Updates the user-editable fields of a document. At least one field is required. Omitting a field preserves its current value; passing `null` clears it.

**Request body**

```json theme={null}
{
  "relatedFolder": "Tender / Drawings / Issued",
  "title": "Head Contract v2",
  "issueDate": "2026-07-01",
  "reviewDate": "2027-07-01"
}
```

* `relatedFolder` — optional. A slash-delimited breadcrumb string up to 2048 characters, or `null` to clear it. Each segment must contain at least one non-whitespace character (`Tender//Issued` is rejected). Leading and trailing whitespace per segment is trimmed; the canonical form uses `/` (space–slash–space) as separator.
* `title` — optional. A display name up to 300 characters, or `null` to clear it. Used on company document lists when set; the UI falls back to `fileName` when absent.
* `issueDate` — optional. Calendar date in `YYYY-MM-DD` format, or `null` to clear it.
* `reviewDate` — optional. Calendar date in `YYYY-MM-DD` format, or `null` to clear it.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Fields updated. |
| `400` | Validation failed, or no fields supplied. |
| `404` | Document not found. |
| `409` | The document has been superseded (`document_superseded`), or the document has been deleted (`code: "deleted"`). |

***

## Delete endpoint

### `DELETE /:id`

Soft-deletes a document. The row is stamped with `deletedAt` and `deletedBy` but is not removed from the database. The document's file, renders, and all pipeline output (extractions, area runs, suggestions) are preserved, so the document remains renderable from history.

**What changes on delete:**

* Document lists (`GET /api/documents?estimateId=`, `?projectId=`, `?partyId=`) no longer return the document.
* The suggestions summary (`GET /suggestions/summary`) excludes the document.
* Any active drive-sync mapping for the document is set to `excluded` in the same transaction, so the file is never re-imported.

**What is preserved:**

* The document row is still accessible via `GET /:id`. The response includes `deletedAt`.
* The upload file, all rendered pages, conversion output, extracted text, area runs, and suggestions remain intact.

**Writing to a deleted document.** Any write to a deleted document returns `409` with `{ code: "deleted" }`: `PATCH /:id`, `POST /:id/route` and unroute, `POST /:id/process`, `POST /:id/retry`, `POST /:id/areas/:area/run`, and the takeoff-pages add, replace and review writes. Reads keep working: `GET /:id` (with `deletedAt`), download, text, page images and thumbnails.

**Idempotency.** A repeat DELETE on an already-deleted document returns `204` with no change.

**Blocking conditions.** The delete is blocked while the document is mid-pipeline (`scanning`, `parsing`, `summarising`, or `processing_areas`). Deleting a routed document with live references in the estimate returns `409 document_in_use`. The estimate-side reference check is server-to-server; returns `502` if the upstream estimate service is unreachable.

**Status codes**

| Code | Reason |
| - | - |
| `204` | Document deleted, or document was already deleted (idempotent). |
| `404` | Document not found. |
| `409` | Document is mid-pipeline and cannot be deleted yet; or a routed document has live estimate references (`document_in_use`). |
| `502` | Upstream estimate service was unreachable during the reference check. |

***

## Upload scoping

Each file in a `POST /api/documents` request must supply exactly one of `projectId` or `partyId`.

| Supplied | `estimateId` allowed | AI processing |
| - | - | - |
| `projectId` + `estimateId` | Yes | Automatic (`auto`) — parse, summarise, classify, area modules |
| `projectId` alone | No | On-demand — stops at `stored` until routed and processed |
| `partyId` alone | No | On-demand — stops at `stored`; cannot be routed to an estimate |

Supplying both `projectId` and `partyId`, or neither, returns `400` with the message `"Provide exactly one of projectId or partyId."` Supplying `estimateId` alongside `partyId` also returns `400`.

### Pinned kind constraints

`contract_agreement` and `day_rates` are company-only kinds. Uploading either without `partyId` returns `400`. Project documents always use `docKind: "other"` (the default); attempting to set a pinned kind on a project document is rejected.

## Routing and processing endpoints

### `POST /:id/route`

Routes a project document into an estimate, or unroutes it. Optionally starts AI processing in the same database transaction.

**Request body**

```json theme={null}
{
  "estimateId": "estimate-uuid",
  "process": true,
  "targetAreas": ["work_items", "project_rates"]
}
```

* `estimateId` — required. UUID of the target estimate, or `null` to unroute.
* `process` — optional boolean. When `true`, the document is moved to processing in the same transaction as the route write.
* `targetAreas` — optional non-empty array. Limits AI processing to the listed areas. Valid values:
  `takeoff`, `work_items`, `project_rates`, `programme_tender`, `subcontracts`. Supplying this field
  requests processing even when `process` is omitted.

**Behaviour**

* The estimate must belong to the document's project. A mismatch returns `422`. Company documents (owned by `partyId`) have no project and always return `422` from this endpoint.
* Re-routing an active document to its current estimate is idempotent. Routing it to a different
  estimate while it is not settled returns `409`.
* Changing an estimate link or unroute requires a settled state (`stored`, `processed`, or
  `failed`); re-routing an active document to its existing estimate is the idempotent exception.
* Processing starts only when the document is stored and scan-clean. A route request can still
  succeed when processing cannot start; its `200` response reports
  `processing: { started: false, reason: "not_stored" }`.
* A `linked` drive document routed with processing requested is fetched first: the response reports
  `processing: { started: false, reason: "fetching", jobId }`, and the fetch job starts processing once
  the copy scans clean. A store-only document routes normally and reports `reason: "store_only"`.
* The estimate-service reference validation is server-to-server. If the upstream service is unreachable, the endpoint returns `502`.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Routing succeeded. The response states whether processing started. |
| `400` | Invalid request body. |
| `404` | Document not found. |
| `409` | The document is superseded, or a non-settled document is being moved to a different estimate. |
| `422` | Estimate does not belong to the document's project. |
| `502` | Upstream estimate service was unreachable. |

***

### `POST /:id/process`

Starts AI processing on a stored, routed document. This is the explicit processing trigger — equivalent to scanning having passed for an uploaded document that was routed with `process: false`.

**Request body** (optional)

```json theme={null}
{
  "targetAreas": ["work_items"]
}
```

* `targetAreas` — optional array. Limits processing to the listed areas. An explicit empty array returns `400`. Omitting the field processes all applicable areas.

**Behaviour**

* A first processing start requires `stored`, a clean scan verdict, a non-null `estimateId`, and no
  superseding document.
* The endpoint uses a compare-and-swap write. A retry or race that finds the document on the active
  processing path (`parsing`, `summarising`, `processing_areas`, or `processed`) with a clean scan
  is a benign `200` no-op with `reason: "already_processing"`.
* A DWG or DXF drawing whose PDF derivative has not yet been produced returns `200` with
  `started: false, reason: "conversion_pending"`. The document will become processable once
  conversion completes. The client should poll and retry.
* A routed `linked` drive document is fetched first: the endpoint creates or joins a fetch job and
  returns `202` with `{ started: false, reason: "fetching", job }`. Processing starts once the copy
  scans clean.
* Store-only documents return `409` with a `store_only:` message. A linked file that can be viewed but not
  processed returns `409 not_processable`, and one that opens only in the drive returns `409 drive_only`.
* Unrouted, infected, superseded, failed, rejected, or otherwise genuinely unprocessable documents
  return `409`. This includes DWG or DXF documents whose conversion has failed.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Processing started; was already running/completed (`reason: "already_processing"`); or a DWG/DXF conversion is still in progress (`reason: "conversion_pending"`, client should poll and retry). |
| `202` | The document is a `linked` drive file; a fetch job was created or joined (`reason: "fetching"`). |
| `400` | Empty `targetAreas` array, or invalid body. |
| `404` | Document not found. |
| `409` | Document is not routed, is not scan-clean, is superseded, or is outside both `stored` and the active processing path. Includes DWG/DXF documents whose conversion has failed. |

***

## Takeoff review endpoint

| Method | Path | Purpose |
| - | - | - |
| `PUT` | `/:id/takeoff-review` | Set the document-level takeoff review decision (`approved` or `rejected`). |

### `PUT /:id/takeoff-review`

Sets or updates the document-level takeoff review decision. Approving a document makes all its pages available on the takeoff canvas; rejecting excludes the document.

**Request body**

```json theme={null}
{
  "status": "approved"
}
```

* `status` — required. Must be `"approved"` or `"rejected"`.

**Response**

```json theme={null}
{
  "id": "doc-uuid",
  "takeoffReviewStatus": "approved",
  "takeoffReviewedAt": "2026-07-13T10:00:00.000Z"
}
```

**Behaviour**

* The document must have `status: "processed"`. A document that is still in the pipeline returns `422 Unprocessable Entity`.
* The decision is changeable. Calling the endpoint again with a different status overwrites the previous decision and refreshes `takeoffReviewedAt`.
* The write is atomic: the `status = processed` precondition and the decision commit in one statement, so a concurrent area rerun cannot produce an inconsistent state.
* A decision does not modify the per-page takeoff page selection stored by `PUT /:id/takeoff-pages`.
* In-flight sync rows are invisible to this endpoint. An ID that names a sync-provisioning row returns `404`, the same as a document that does not exist.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Decision written. |
| `400` | Invalid `status` value. |
| `404` | Document not found. |
| `422` | Document is not in `processed` state. |

## Page render endpoints

These endpoints serve rendered PDF pages for the takeoff picker and canvas, and hold each page's drawing scale. Rendering and scale detection run on the render worker. The API never renders pages on the request path: a page that is not ready is queued for the worker. Setting a scale with the same-size fill reads page sizes from the cached `page-sizes.json`, and parses the PDF once when that file is missing.

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/:id/thumbnails` | Return every page's thumbnail status and presigned URL in one request. |
| `GET` | `/:id/page-thumbnails` | Return presigned thumbnail URLs for rendered pages, plus progress metadata. |
| `GET` | `/:id/pages/:page/image` | Return a presigned URL for one full-resolution page render. |
| `GET` | `/:id/takeoff-pages` | Return the current saved takeoff page selection. |
| `POST` | `/:id/takeoff-pages/add` | Add pages to the takeoff selection and enqueue full-resolution renders. |
| `PUT` | `/:id/takeoff-pages` | Replace the takeoff page selection and enqueue full-resolution renders. |
| `PUT` | `/:id/takeoff-pages/:page/scale` | Set one page's drawing scale and, by default, fill every unscaled page of the same sheet size. |
| `DELETE` | `/:id/takeoff-pages/:page/scale` | Clear one page's drawing scale, whatever its source. |
| `POST` | `/:id/takeoff-pages/:page/scale-detection` | Read the scale and sheet size stated on a page. Returns a suggestion only. |

Two routes take an `async` query flag (`1` or `true`): `GET /:id/pages/:page/image` and `POST /:id/takeoff-pages/:page/scale-detection`. With `async=1`, a page that is not ready is answered at once with `202` and a `retryAfterMs` for the client to poll after. Without it, the route behaves for older clients that read any `2xx` as the finished answer, as described under each route.

### `GET /:id/thumbnails`

Returns every page's thumbnail status and, for ready pages, a presigned URL. One access check and one storage listing cover the whole document, so the picker does not need one request per page.

**Query parameters**

| Parameter | Description |
| - | - |
| `from` | Optional. First page of the range, inclusive (1–10,000). Defaults to `1`. |
| `to` | Optional. Last page of the range, inclusive (1–10,000). Defaults to the document's page count. `from` must not be after `to`. |

At most 1,000 pages can be requested in one call. A larger document asks by `from` and `to`.

**Response**

```json theme={null}
{
  "expiresAt": "2026-10-06T10:00:00.000Z",
  "pageCount": 10,
  "thumbnails": [
    { "page": 1, "status": "ready", "url": "https://..." },
    { "page": 2, "status": "pending" },
    { "page": 3, "status": "failed" }
  ]
}
```

* `expiresAt`: taken before signing, so it is never later than any URL's own expiry.
* `pageCount`: the document's page count, or `0` while the parse has not counted the pages yet.
* `thumbnails`: one entry per page in the range. `status` is `ready` (with `url`), `pending` (not rendered yet), or `failed` (the page's render failed after its retries).

When a page is `pending`, the endpoint queues a render of the document unless a job already covers it. The enqueue is debounced, so polling never stacks jobs. Poll again while `pageCount` is `0` or any page is `pending`.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Statuses returned. |
| `400` | `from` is after `to`, or the range is wider than 1,000 pages. |
| `404` | Document not found, or not a scan-clean renderable document. |

### `GET /:id/page-thumbnails`

Returns presigned URLs for every thumbnail rendered so far, a total page count, and a `complete` flag.

```json theme={null}
{
  "items": [
    { "page": 1, "url": "https://..." },
    { "page": 2, "url": "https://..." }
  ],
  "pageCount": 10,
  "complete": false
}
```

* `items` — presigned URLs for contiguous pages rendered so far (pages 1 through `thumbnailPageCount`).
* `pageCount` — the document's known total page count, or the rendered count when the total is not yet available.
* `complete` — `true` when every page is rendered. `false` while rendering is in progress, while a DWG drawing is still being converted, or when the total page count is not yet known. `true` (with an empty `items` array) when the document is not a scan-clean PDF and conversion is not expected, so the picker degrades to numbered placeholders rather than polling indefinitely.

**Render offload behaviour.** When `complete` is `false` for a scan-clean PDF, the endpoint enqueues a background render job for the pipeline worker and returns immediately with whatever is done so far. The client should poll until `complete` is `true`. Polling is debounced server-side: repeat calls within 30 seconds do not stack duplicate render jobs.

**DWG/DXF conversion behaviour.** While a drawing is still being converted to a PDF derivative, the endpoint returns `complete: false` with an empty `items` array. The client should continue polling: once conversion finishes, subsequent calls will include rendered thumbnail URLs and eventually return `complete: true`. If conversion fails terminally, the endpoint returns `complete: true` (no thumbnails will ever be available), and the document will report a conversion failure through `POST /:id/process`.

### `GET /:id/pages/:page/image`

Returns a presigned URL for one full-resolution page image (used by the takeoff canvas), together with the page's true dimensions. Only available for scan-clean renderable documents.

**Query parameters**

| Parameter | Description |
| - | - |
| `async` | Optional. `1` answers a page that is not rendered yet with `202` instead of waiting. |
| `retry` | Optional, with `async=1`. `1` queues a page whose render has failed again. |

When the page is rendered:

```json theme={null}
{
  "page": 1,
  "url": "https://...",
  "expiresAt": "2026-07-12T15:00:00Z",
  "width": 3508,
  "height": 2480,
  "pageWidthPt": 841.89,
  "pageHeightPt": 595.28
}
```

* `page` — the requested page number.
* `url` — presigned download URL for the full-resolution render.
* `expiresAt` — ISO 8601 expiry timestamp for the presigned URL.
* `width` / `height` — the render's actual pixel dimensions, read from the PNG header. Use these to derive the true aspect ratio without guessing a paper size.
* `pageWidthPt` / `pageHeightPt` — the PDF page box in points (1 pt = 1/72 in). Use these together with `width` / `height` to convert a pixel distance to millimetres exactly: `mm = px / width × pageWidthPt / 72 × 25.4`.
* Any field the server cannot establish is omitted from the response. Absence means "not measured" — the `url` is still valid and is always returned.

When the page is not rendered yet, it is queued for the render worker once, however often it is asked for:

* With `async=1`, the answer is `202 { "page": 1, "status": "rendering", "retryAfterMs": 2000 }`. Ask again after `retryAfterMs`.
* With `async=1`, a page whose render failed after its retries answers `200 { "page": 1, "status": "failed" }` until `retry=1` queues it again.
* Without `async=1`, the request waits up to 20 seconds for the worker and then returns the image. If the render is still not ready, it returns `503` with a `Retry-After: 2` header and `{ "message": "This page is still being prepared. Please try again in a moment.", "retryAfterSeconds": 2 }`. A failed page is queued again.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Image URL returned, or `status: "failed"` with `async=1`. |
| `202` | Render queued (`async=1` only). |
| `404` | Document not found, not a scan-clean renderable document, or page out of range. |
| `503` | The render was not ready within the wait (without `async=1`). |

The takeoff canvas prefers `width` and `height` for its aspect ratio, then the PDF point box, then the estimator-selected paper size. It uses the point box for physical sheet dimensions; when that pair is absent, measurements fall back to the selected paper size and the UI labels the sheet as assumed.

### `GET /:id/takeoff-pages`

Returns the current saved takeoff page selection for a document.

**Response**

```json theme={null}
{
  "pages": [
    { "page": 1 },
    {
      "page": 3,
      "scale": "1:100",
      "scaleSource": "auto",
      "scaleAuto": { "detectedFrom": "text", "detectedAt": "2026-10-07T04:00:00.000Z" }
    }
  ],
  "version": 2
}
```

* `pages` — the saved selection, sorted ascending by page number. Each entry has a `page` number and an optional `scale` string.
* `scaleSource`: only on a scaled page: `manual` (set by the estimator), `filled` (given by the same-size fill when the estimator set another page), or `auto` (set by the render worker from the page's one clear scale). A scaled page with no source is `manual`.
* `scaleAuto`: only with `scaleSource: "auto"`: `detectedFrom` (`text` or `vision`) and the detection's `detectedAt`.
* `version` — monotonically increasing integer, bumped whenever the set changes. Echo this value in `PUT /:id/takeoff-pages` to prevent stale overwrites.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Selection returned. |
| `404` | Document not found. |

***

### `POST /:id/takeoff-pages/add`

Adds one or more pages to the saved takeoff selection. This is an add-only, idempotent merge: adding a page that is already in the selection is a no-op and never overwrites an existing page's scale (first write wins). The version is bumped only when the set actually changes.

**Request body**

```json theme={null}
{
  "pages": [
    { "page": 2 },
    { "page": 4, "scale": "1:200" }
  ]
}
```

* `pages` — required, 1–500 entries. Each entry is a page number (1–10,000) or an object with `page` and an optional `scale` string (up to 40 characters).
* A bare page number is accepted and normalised to `{ page }`.
* All pages must be within the document's known page count; requests that exceed it return `422`.

**Response**

```json theme={null}
{
  "pages": [
    { "page": 1 },
    { "page": 2 },
    { "page": 4, "scale": "1:200" }
  ],
  "version": 3
}
```

**Behaviour**

* The merge and version bump run inside a single transaction with a row lock, so concurrent adds and replaces cannot race each other.
* Adding a page that is already selected is a no-op; the existing entry's scale is preserved.
* Replaying an identical add does not bump the version.
* For a renderable document with at least one selected page, full-resolution renders are enqueued after each call, including replays, so a failed warm-up from a previous call can be retried.
* In-flight sync rows are invisible to this endpoint. An ID that names a sync-provisioning row returns `404`, the same as a document that does not exist.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Pages merged into the selection. |
| `400` | Invalid request body. |
| `404` | Document not found. |
| `422` | One or more pages exceed the document's known page count. |

***

### `PUT /:id/takeoff-pages`

Replaces the entire saved takeoff selection. Requires the caller to echo the `version` it last read. A stale version is rejected with the current selection so the caller can reapply the user's changes over the fresh set.

**Request body**

```json theme={null}
{
  "pages": [
    { "page": 1 },
    { "page": 3, "scale": "1:100" }
  ],
  "version": 2
}
```

* `pages` — required, 0–500 entries. Accepts the same shapes as `POST /:id/takeoff-pages/add`. An empty array clears the selection.
* `version` — required. Must match the version returned by the most recent `GET /:id/takeoff-pages` or write response. A mismatch returns `409` with the current `{ pages, version }` under a `current` key.

**Response**

```json theme={null}
{
  "pages": [
    { "page": 1 },
    { "page": 3, "scale": "1:100" }
  ],
  "version": 3
}
```

**Behaviour**

* The version check and the write share one transaction under a row lock, so a concurrent add cannot slip between the check and the write.
* A replace that resolves to the identical set writes nothing and does not bump the version.
* A page sent back with the scale it already had keeps its `scaleSource`. A page whose scale changed, or that newly has one, becomes `manual`.
* For a renderable document with at least one saved page, full-resolution renders are enqueued after every call, including no-op saves, so a previously failed warm-up is retried. Clearing the selection enqueues nothing.
* In-flight sync rows are invisible to this endpoint. An ID that names a sync-provisioning row returns `404`, the same as a document that does not exist.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Selection replaced. |
| `400` | Invalid request body. |
| `404` | Document not found. |
| `409` | Version mismatch. Response body contains `current: { pages, version }`. |
| `422` | One or more pages exceed the document's known page count. |

***

### `PUT /:id/takeoff-pages/:page/scale`

Sets the drawing scale on one page, then gives the same scale to every other page of the document that has no scale yet and is the same sheet size. The set page is added to the selection if it is not already there.

**Request body**

```json theme={null}
{
  "scale": "1:200",
  "fillUnscaledPages": true
}
```

* `scale`: required. The ratio as written, up to 40 characters.
* `fillUnscaledPages`: optional, defaults to `true`. `false` writes only the named page.

**Response**

```json theme={null}
{
  "pages": [
    { "page": 1, "scale": "1:200", "scaleSource": "filled" },
    { "page": 2, "scale": "1:200", "scaleSource": "manual" },
    { "page": 3, "scale": "1:100", "scaleSource": "manual" }
  ],
  "version": 5,
  "filledPages": [1],
  "skippedPages": { "differentSize": [4], "unknownSize": [], "notToScale": [5] },
  "pageSize": { "widthMm": 841.0, "heightMm": 594.0, "sheet": "A1" }
}
```

* `pages` and `version`: the saved selection after the write. The version is bumped only when the set changed.
* `filledPages`: the other pages that gained the scale. Never includes the set page.
* `skippedPages`: unscaled pages left alone: `differentSize` (a different sheet size), `unknownSize` (a size that could not be read, or every page when the set page's own size is unknown), and `notToScale` (the page's cached scale detection says it is not to scale).
* `pageSize`: the set page's size in millimetres and ISO sheet name (`sheet` is `null` for a non-ISO size), or `null` when unknown.

**Behaviour**

* The set page always takes the new scale and is recorded as `manual`. Filled pages are recorded as `filled`.
* Two pages are the same sheet size when each edge matches within 2.5%, either way up, using the PDF's page boxes. A page that already has a scale is never changed, whatever its source.
* The write runs in one transaction under the row lock. There is no version echo: the fill only writes pages that are empty at the locked read.
* Only the set page's full-resolution render is warmed. Filled pages render when they are opened.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Scale set, and the fill applied when enabled. |
| `400` | Invalid request body. |
| `404` | Document not found. |
| `409` | The document has been deleted (`code: "deleted"`). |
| `422` | The page exceeds the document's known page count. |

***

### `DELETE /:id/takeoff-pages/:page/scale`

Clears one page's scale and its source, whatever set it. This is how an estimator undoes a scale the render worker set automatically. The page stays in the selection, unscaled.

**Response**

```json theme={null}
{
  "page": 3,
  "pages": [{ "page": 1 }, { "page": 3 }],
  "version": 6
}
```

The version is bumped only when a scale was cleared. Clearing a page with no scale is a no-op that still returns `200`.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Scale cleared, or there was nothing to clear. |
| `400` | Invalid path parameters. |
| `404` | Document not found. |
| `409` | The document has been deleted (`code: "deleted"`). |
| `422` | The page exceeds the document's known page count. |

***

### `POST /:id/takeoff-pages/:page/scale-detection`

Reads the scale and sheet size the page states, and what they mean on this page. The result is a suggestion: nothing is saved. The client saves a confirmed scale through `PUT /:id/takeoff-pages/:page/scale`, like a scale set by hand.

**Request body** (optional)

```json theme={null}
{ "refresh": true }
```

* `refresh`: optional. `true` reads past a cached answer (including a cached "nothing found") and queues a fresh detection. The **Find scale on drawing** button sends it. Poll without `refresh`, so polling never restarts the job.

**Behaviour**

* A cached answer is returned straight away with `200`.
* Otherwise the page is queued for the render worker. With `async=1`, the answer is `202 { "page": 3, "status": "pending", "retryAfterMs": 2000 }`. Without it, the answer is `200` with `status: "unavailable"`.
* A failed detection answers `status: "unavailable"` once and is not cached, so the next request asks again. The estimator can always set the scale by hand.

**Response** (a detection)

```json theme={null}
{
  "version": 2,
  "page": 3,
  "status": "found",
  "pageSize": { "widthMm": 841.0, "heightMm": 594.0, "sheet": "A1" },
  "candidates": [
    {
      "statedScale": "1:200",
      "statedSheetSize": "A1",
      "check": "matches",
      "appliedScale": "1:200",
      "fromLongEdgeScale": null,
      "axis": null,
      "fromViewTitle": false,
      "scaleEvidence": { "text": "SCALE 1:200 @ A1", "box": { "x": 0.82, "y": 0.94, "width": 0.08, "height": 0.02 } },
      "sheetSizeEvidence": null,
      "sheetSizesDisagree": false
    }
  ],
  "preselected": 0,
  "notToScale": false,
  "source": "text",
  "visionChecked": false,
  "detectedAt": "2026-10-07T04:00:00.000Z"
}
```

* `status`: `found` (one scale that can be applied as is), `choose` (more than one scale, or a sheet size mismatch), `none` (nothing states a scale), or `unavailable` (detection could not run; `candidates` is empty).
* `candidates`: each scale statement found: the ratio as written (`statedScale`), the sheet size named with it (`statedSheetSize`), how that size compares with the page (`check`: `matches`, `converted`, `mismatch` or `not-stated`), the ratio to save (`appliedScale`, `null` when the estimator must choose), and where the text sits as page fractions (`scaleEvidence.box`). `fromViewTitle` is `true` for a scale read from a view title such as "SECTION A 1:20".
* `preselected`: the index of the candidate to start on (the title-block scale), or `null` when nothing was found.
* `notToScale`: `true` when the drawing says it is not to scale.
* `source`: `text` or `vision`, or `null` when nothing was found. `visionChecked` says whether a vision read was attempted.

**Status codes**

| Code | Reason |
| - | - |
| `200` | A detection, or `status: "unavailable"`. |
| `202` | Detection queued or running (`async=1` only). |
| `404` | Document not found, not a scan-clean renderable document, or page out of range. |

***

## Suggestion endpoints

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/suggestions/summary?estimateId=` | Count pending, accepted, and rejected suggestions by area and document. Takeoff suggestions are excluded from pending counts. |
| `GET` | `/suggestions/:id` | Fetch a single suggestion by ID (server-to-server use). |
| `GET` | `/:id/suggestions?area=&status=` | List suggestions for a document, optionally filtered by area and status. |
| `PATCH` | `/suggestions/:id` | Edit a pending suggestion payload, kind, or area. Changing area revalidates against the new area schema. |
| `POST` | `/suggestions/:id/accept` | Mark a suggestion accepted after the frontend has created the target record. Takeoff `page_classification` suggestions do not require an `appliedRecordId`. |
| `POST` | `/suggestions/:id/reject` | Reject a pending suggestion. |

## Cloud drive sync endpoints

These endpoints manage drive identities, drive sources and project mappings for cloud-drive document sync. Identity, source and connection mutations require an admin JWT. The onboarding-config, check-access and folder-browse routes are also admin-only. Other reads and `sync-now` are not admin-gated.

While the drive-sync kill switch is closed, every identity, source and connection mutation, `sync-now`, `check-access` and folder browse returns `409 drive_sync_disabled`. Read-only list and detail routes are unaffected. See [Cloud drive sync](https://help.stralos.app/internal/drive-sync.md#drive-sync-kill-switch).

### Drive identity endpoints

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/drive-identities` | The organisation's drive identities and whether drive sync is enabled. Any member may read it. |
| `POST` | `/drive-identities/google` | Provision or resume the organisation's Google service account. Returns `202 { identity }`. |
| `POST` | `/drive-identities/microsoft/tenant-lookup` | Look up a Microsoft 365 tenant from a domain and return the admin-consent URL. |
| `POST` | `/drive-identities/microsoft/signin-start` | Start the Microsoft sign-in that binds the tenant; returns the authorisation URL. |
| `GET` | `/drive-identities/microsoft/consent-done` | Static landing page for the admin-consent redirect. |
| `GET` | `/drive-identities/microsoft/signin-callback` | Microsoft's redirect target; binds the tenant and redirects to **Settings → Shared drives** with `?microsoft=<outcome>`. |
| `POST` | `/drive-identities/:id/disconnect` | Disconnect an identity and disable its sources. |
| `POST` | `/drive-identities/:id/reconnect` | Reconnect within the 30-day grace period. |

See [Per-organisation drive identities](https://help.stralos.app/internal/drive-sync.md#per-organisation-drive-identities) for the lifecycle and outcome codes.

### Drive source endpoints

| Method | Path | Purpose |
| - | - | - |
| `GET` | `/drive-sources/onboarding-config` | Return whether drive sync is enabled and, per provider, whether the organisation can register a drive now: the organisation's service-account email (Google) or Entra client id and bound tenant (Microsoft), plus its identity. |
| `POST` | `/drive-sources/check-access` | Before saving, check whether the organisation's Microsoft identity can read a SharePoint site. Returns `{ ok: true, site }` or `{ ok: false, code }`. Writes nothing. Rate-limited to 30 per minute. |
| `POST` | `/drive-sources` | Register a drive source from a URL. Returns the source and a proof file name. Idempotent: returns the existing source if already registered. |
| `POST` | `/drive-sources/:id/verify` | Complete proof-of-control for `pending_proof`, or re-verify access for `auth_error` / `error`. The single recovery door out of any non-disabled failure state. |
| `GET` | `/drive-sources` | List sources with mapping counts (Settings view). Each item includes `proofFileName` when the source is `pending_proof`. Returns `200` with `{ "items": [] }` when the organisation has no drive sources. |
| `GET` | `/drive-sources/:id/folders` | Browse folders for the mapping picker. Pass `parentId` to list a subfolder. |
| `PATCH` | `/drive-sources/:id` | Rename a source, or toggle `active` ↔ `disabled`. Failure states recover only via `/verify`. |
| `DELETE` | `/drive-sources/:id` | Soft-delete a source. Returns 409 if any live mappings reference it. |

### Sync connection endpoints

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/sync-connections` | Map a source (or folder within it) to a project. Idempotent; reactivates a soft-deleted mapping with a reconciliation crawl. |
| `GET` | `/sync-connections?projectId=` | List mappings for a project with the latest run's stats. |
| `GET` | `/sync-connections?sourceId=` | List mappings for a source (Settings view). |
| `GET` | `/sync-connections/:id` | Detail including recent runs and the skipped, errored, failed, or excluded file list. |
| `PATCH` | `/sync-connections/:id` | Pause or resume a mapping. Pause breaks any in-flight run fence. |
| `DELETE` | `/sync-connections/:id` | Soft-delete a mapping and break the run fence. |
| `POST` | `/sync-connections/:id/files/:fileId/reinclude` | Re-include an excluded, errored, or quarantined file. Seeds a reconciliation crawl. Admin-only. |
| `POST` | `/sync-connections/:id/sync-now` | Clear the dispatch claim window so the next dispatcher tick picks the mapping up. Returns 409 if the mapping or source is not active, a run is already in flight, or the project is lost or archived (`project_sync_paused`). Not admin-gated. |

### Drive source states

| State | Description |
| - | - |
| `pending_proof` | Registered; waiting for proof-of-control verification |
| `active` | Verified; sync runs normally |
| `auth_error` | Provider access revoked, the organisation's identity was refused, or the identity was reconnected; recover with `/verify` |
| `error` | Platform-side federation failure; recover with `/verify` after ops fixes the platform |
| `disabled` | Admin-disabled, or disabled by an identity disconnect; re-enable with `PATCH` |

### Synced file states

Synced files (returned in `GET /sync-connections/:id`) can be:

| State | Description |
| - | - |
| `synced` | Linked in Stralos; the document's bytes may or may not have been fetched yet |
| `skipped` | Temporary or system file, the proof file, or a provider-specific skip (shortcut, remote item, package) |
| `error` | The file could not be placed (for example, its ancestry could not be resolved); retried on the next sync cycle |
| `excluded` | Admin-excluded; not re-synced until re-included |
| `failed` | Copy-era quarantine state; no longer written. A new version or an explicit re-include links the file again |

## Link-first drive endpoints

Synced drive files are stored as `linked` documents with no bytes. These endpoints open and fetch them, manage drive versions, and record which entities use which documents. Every call runs under the caller's organisation and project membership, except the service-token usage `PUT`. Wire types live in `@stralos-co-nz/domain`; see [Cloud-drive document sync](https://help.stralos.app/internal/cloud-drive-sync.md#link-first-drive-sync).

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/:id/open` | Open a document: serve a held copy, ask the user to choose between versions, or create or join a fetch job for a linked row. |
| `GET` | `/drive-fetches/:jobId` | Poll a fetch job. Includes a download when the job is `ready`. |
| `POST` | `/:id/drive-version` | Add the drive's newer version beside the one an entity used (`action: "sync"`), or fetch it without routing (`action: "fetch_version"`). Returns `202 { state: "fetching", job }`. |
| `POST` | `/prefetch` | Queue fetch jobs for an entity's linked documents. Body: `{ entityType, entityId }`. |
| `POST` | `/usages/:entityType/:entityId/acknowledgements` | Record an entity's decision about one flag: `newer_version_kept`, `removed_from_drive`, or `older_version_kept`. |
| `DELETE` | `/usages/:entityType/:entityId/acknowledgements/:documentId/:kind` | Remove an acknowledgement so the flag shows again. Returns `204`. |
| `GET` | `/usages/:entityType/:entityId/changes` | Return the entity's source documents with their change flags and counts. |
| `PUT` | `/usages/:module/:entityType/:entityId` | Register a module's full usage set for an entity. `service:<module>` token only. |
| `POST` | `/projects/:projectId/processing-recommendations` | Ask the AI which of a project's documents to process, from names and folder paths only. |

While the drive-sync kill switch is closed, `drive-version`, `prefetch` and processing recommendations return `409 drive_sync_disabled`. `/open` still serves held copies. The usage registry routes are never gated.

### `POST /:id/open`

**Request body** (all optional)

* `version`: `"auto"` (default), `"keep"`, or `"latest"`. `keep` and `latest` act for the entity the open came from.
* `entityType` and `entityId`: the entity the open came from. Supply both or neither.

**Response**, discriminated on `state`:

| `state` | HTTP | Meaning |
| - | - | - |
| `ready` | `200` | A held copy is served. Includes `download` (`url`, `expiresAt`), `versionLabel`, whether a newer version exists, and `versionCheck` (`live`, `unavailable`, or `not_applicable`). |
| `decision_required` | `200` | A newer drive version exists and the document is used by a non-frozen entity. Includes the `used` and `latest` versions, the `entities` that must choose, and the areas already processed. |
| `fetching` | `202` | No copy is held. A fetch job was created or joined; poll `GET /drive-fetches/:jobId`, then open again. |
| `drive_only` | `200` | The file is above the 2 GB fetch ceiling (`too_large`) or cannot be downloaded (`not_downloadable`). Open it in the drive using `webUrl`. |
| `unavailable` | `200` | The file cannot be fetched: `deleted_in_drive`, `access_revoked`, `scan_threat`, or `sync_disabled`. |

A user can create up to 30 fetch jobs per 10 minutes; joining a live job does not count. Beyond that, the endpoint returns `429 too_many_fetches`.

### `GET /drive-fetches/:jobId`

Returns `{ job, download? }`. The job carries `status` (`queued`, `fetching`, `storing`, `scanning`, `ready`, or `failed`), `step` (1 to 4 for the progress display), `kind`, `origin`, `bytesDone` and `bytesTotal`, `failureReason`, `processingOutcome`, `resultDocumentId` (the row the bytes land in), and `versionLabel` once ready.

Failure reasons:

| Reason | Meaning |
| - | - |
| `too_large` | The file is above the 2 GB fetch ceiling |
| `not_downloadable` | The drive does not allow the file to be downloaded, including Google exports over Google's 10 MB limit |
| `access_revoked` | Stralos lost access to the drive |
| `deleted_in_drive` | The file was deleted from the drive |
| `scan_threat` | The malware scan found a threat |
| `scan_error` | The malware scan could not finish |
| `changed_during_fetch` | The file changed in the drive while it was being fetched |
| `provider_error` | The drive provider did not respond as expected |
| `timeout` | The fetch took too long |
| `sync_disabled` | The drive-sync kill switch closed during the fetch |

### `POST /:id/drive-version`

| Field | Required | Meaning |
| - | - | - |
| `action` | Yes | `sync` or `fetch_version` |
| `latestVersionMarker` | Yes | The drive version the caller saw |
| `entityType`, `entityId` | With `sync` | The entity the new version is for. It must not be frozen |
| `processAreas` | No | Areas to process once the new version scans clean (`sync` only) |

Conflicts return `409` with `version_changed` (includes the drive's `latest` version), `no_newer_version`, `entity_frozen`, or `document_superseded` (includes `currentDocumentId`).

### `POST /prefetch`

Queues fetch jobs for the linked documents an entity uses, for files up to 250 MB, and returns `202 { queued, joined, skipped }`. Each call queues at most 25 jobs, and the organisation can queue at most 100 per hour; documents beyond either limit are listed in `skipped` with reason `prefetch_limit`.

* A repeat call for the same entity within 30 minutes returns `202 { queued: 0, joined: 0, skipped: [] }`, because the entity is already warm or warming.
* When the organisation has already used its hourly budget, the call returns `429 prefetch_throttled` with `retryAfterSeconds`. This is the only case that returns `429`.

### `GET /usages/:entityType/:entityId/changes`

Returns `{ entity, sources, counts }`. `entity` is `null` when nothing is registered. Each source carries the document's `driveLink`, the version `delta`, any newer Stralos version, whether it was removed from the drive, the entity's acknowledgements, and three flags: `promptWorthy`, `flagItems`, and `possibleVariation`. `counts` totals `changed`, `removed`, `promptWorthy`, `flagged`, and `possibleVariations`.

### `PUT /usages/:module/:entityType/:entityId`

Replaces an entity's full document usage set. It takes the same body as the `DocumentUsagesChanged` v1 bus event, for modules without a bus publisher. A document absent from `usages` is no longer used by the entity.

```json theme={null}
{
  "frozen": false,
  "usageVersion": 42,
  "label": "Earthworks estimate",
  "projectId": "project-uuid",
  "usages": [
    { "documentId": "doc-uuid", "purpose": "takeoff_measurement", "count": 3 }
  ]
}
```

* `frozen`: required. `true` when the entity is locked, for example a submitted estimate. Changes then produce possible-variation flags rather than prompts.
* `usageVersion`: required integer, 0 or more, rising per entity. The set is applied only when this is higher than the version held.
* `label`: optional string or `null`. Surrounding whitespace is trimmed, a blank value is stored as `null`, and a value longer than 200 characters is cut to 200. It is never a reason to reject the set.
* `projectId`: optional UUID or `null`.
* `usages`: required array of up to 5,000 entries. `purpose` is 2 to 40 characters of lower-case letters, digits and underscores, starting with a letter. `count` is 1 to 1,000,000.

Returns `{ "applied": true }`, or `{ "applied": false }` when the version was not higher. A caller whose token is not `service:<module>` for the path's module receives `403`.

### `POST /projects/:projectId/processing-recommendations`

Asks the AI which of the project's documents are worth processing. The AI sees file names and folder paths only, never file contents. Nothing is routed or processed; the user chooses and routes documents separately.

There is no request body. The endpoint considers up to 500 of the project's `linked` and `stored` documents that are not superseded or deleted.

```json theme={null}
{
  "recommendations": [
    {
      "documentId": "doc-uuid",
      "fileName": "Site Plan - Civil Works.pdf",
      "folderPath": "Tender / Drawings",
      "reason": "Site plan drawing, likely suitable for takeoff.",
      "suggestedAreas": ["takeoff"]
    }
  ]
}
```

* `folderPath`: the document's `relatedFolder`, or `null`.
* `suggestedAreas`: de-duplicated area keys: `takeoff`, `work_items`, `project_rates`, `programme_tender`, or `subcontracts`.

An empty array means there were no eligible documents or the AI suggested none. Each user can call the endpoint five times per hour; beyond that it returns `429 too_many_recommendations` with `Retry-After: 3600`.

## Schedule export endpoint

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/:id/schedule-export` | Use AI to identify the schedule layout in an uploaded Excel workbook, write authoritative prices into the matched cells, validate every item is matched, and append a TAGs / departures sheet. Returns the completed workbook. |

### `POST /:id/schedule-export`

Takes an imported `.xlsx` or `.xlsm` workbook already stored as a project document, locates its schedule columns using an AI completion, writes the authoritative rates and amounts from the estimate, and returns the completed file as a download.

**Request body**

```json theme={null}
{
  "estimateId": "estimate-uuid",
  "items": [
    {
      "itemNumber": "1.1",
      "description": "Excavate",
      "quantity": 4,
      "unit": "m",
      "rate": 150,
      "amount": 600
    }
  ],
  "tags": [
    {
      "listKey": "departures",
      "code": "D1",
      "text": "Rock excluded",
      "linkedItemNumbers": ["1.1"]
    }
  ]
}
```

* `estimateId` — required. The estimate the workbook belongs to. The document identified by `:id` must belong to the same estimate.
* `items` — required array, 1–2,000 entries. Each item is a priced schedule line.
  * `itemNumber` — the item's reference number as it appears in the schedule, or `null` when there is no item number column.
  * `description` — the item description.
  * `quantity` — the item quantity.
  * `unit` — the unit of measurement.
  * `rate` — the unit rate to write into the workbook, or `null` to leave the matched row's rate cell unchanged.
  * `amount` — the line amount to write. Required.
* `tags` — required array, up to 5,000 entries; an empty array is valid. Each entry becomes a row on the **TAGs - departures** sheet appended to the workbook.
  * `listKey` — the clause list this tag belongs to (for example, `"departures"` or `"assumptions"`).
  * `code` — the clause reference code.
  * `text` — the clause text.
  * `linkedItemNumbers` — schedule item numbers this clause relates to.

**Behaviour**

* The document must be a `.xlsx` or `.xlsm` file with a clean scan verdict and no `syncAttempt` flag.
* The AI surveys up to the first 10 sheets and first 100 rows of each to identify where each schedule table's header row sits and which columns hold item number, description, rate, and amount. The AI does not calculate, change, or invent any value — it only identifies column positions.
* A column mapping with confidence below 0.75 is rejected as ambiguous.
* Rates and amounts are written only into rows that match an item exactly (by normalised item number or normalised description). A match that is duplicated in the workbook returns `422`. An item with no matching row returns `422`.
* A **TAGs - departures** sheet is always replaced or appended with four columns: **Type**, **Reference**, **Text**, and **Linked schedule items**. An empty `tags` array produces the header row with no tag entries.
* The response is the completed Excel file. The `Content-Disposition` header sets a filename of the form `<original stem> - Priced.<ext>`.
* If the completed workbook exceeds 4 MB, the request returns `422`.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Completed workbook returned as an Excel attachment. |
| `400` | Invalid request body, or an empty `items` array. |
| `404` | Document not found, or document does not belong to the supplied `estimateId`. |
| `422` | Source file is not an Excel workbook; workbook has not passed malware scanning; scan tag is not clean; AI layout identification was ambiguous; one or more schedule items could not be matched in the workbook; a schedule item appears more than once; or the completed workbook exceeds 4 MB. |

***

## Variation assessment endpoints

These service-to-service endpoints run an AI-assisted contract-variation assessment against selected contract documents. They are called by the Estimate service, not directly from the browser.

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/variation-assessments` | Enqueue an assessment job. Returns `{ jobId }` with HTTP 202. |
| `GET` | `/variation-assessments/:id` | Poll job status and, when complete, retrieve the assessment result and coverage report. |

## Tender scope draft endpoint

This endpoint drafts a project scope with AI from the estimate's items and bounded excerpts of the project's processed documents. The browser calls it from the estimate's **Tender download** tab with the user's token. The draft is returned for review and is never saved here; the estimator saves a reviewed scope through `PUT /api/global/projects/:id`.

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/projects/:projectId/tender-scope-draft` | Draft a reviewable project scope. |

### `POST /projects/:projectId/tender-scope-draft`

**Request body**

```json theme={null}
{
  "projectName": "Grafton Road Upgrade",
  "items": [
    { "description": "Excavate to formation level", "section": "Earthworks", "quantity": 4500, "unit": "m³" },
    { "description": "Supply and lay AC 20 wearing course", "section": "Surfacing", "quantity": 2800, "unit": "m²" }
  ]
}
```

* `projectName`: required, 1 to 1,000 characters after trimming.
* `items`: required, 1 to 10,000 entries, each with `description` (up to 4,000 characters), `section` (up to 1,000), `quantity` (a finite number or `null`), and `unit` (up to 100). No other fields are accepted.

The serialised body must be at most 200,000 bytes; a larger one is `400` with "This estimate is too large to draft a scope. Reduce the selected estimate items." The raw request limit is 250,000 bytes.

**Document excerpts.** The service reads up to 20 of the project's current documents with extracted text, ordered by file name, leaving out deleted, superseded, infected, and rejected files. It takes up to 12,000 characters from each and 60,000 in total.

**Response**

```json theme={null}
{
  "scope": {
    "type": "doc",
    "content": [
      { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Earthworks" }] },
      { "type": "paragraph", "content": [{ "type": "text", "text": "Bulk earthworks to formation level..." }] }
    ]
  },
  "documentsUsed": ["Site Investigation Report.pdf"],
  "documentsArePartial": true
}
```

* `scope`: a rich-text document of level 2 `heading` and `paragraph` nodes, in the same format as the project scope. The model is asked for at most 8 sections and 2,000 words.
* `documentsUsed`: the file names whose text was included.
* `documentsArePartial`: always `true`, because excerpts are bounded. Review the draft against the original documents.

**Limits.** Each signed-in user can create ten drafts an hour per organisation; the eleventh is `429` with `Retry-After: 3600` and "You have used your ten AI scope drafts for this hour. Please try again later." The route also allows at most five requests a minute.

**Status codes**

| Code | Reason |
| - | - |
| `200` | Draft returned. |
| `400` | Invalid body, or the serialised body is over 200,000 bytes. |
| `401` | Authentication required. |
| `403` | The caller cannot access the project, or the token has no signed-in user. |
| `429` | The hourly draft limit or the per-minute rate limit was reached. |
| `502` | `tender_scope_ai_unavailable`: the AI failed, timed out after 90 seconds, or returned an invalid draft. The message is "The AI couldn't create a scope draft. Please try again." |

***

## Tender check endpoints

These service-to-service endpoints run an AI-assisted tender check of an estimate against selected project documents. The Estimate service is the only caller and forwards the user's token; the job's organisation comes from that token. No model work runs on the request path: jobs run on the tender check worker.

| Method | Path | Purpose |
| - | - | - |
| `POST` | `/tender-check-recommendations` | Queue a job that suggests which project documents are worth including. Returns `202 { jobId }`. Rate-limited to 30 per minute. |
| `POST` | `/tender-check-reviews` | Queue a review job that checks the estimate against the chosen documents. Returns `202 { jobId }`. Rate-limited to 10 per minute. |
| `GET` | `/tender-check-jobs/:id` | Poll a job's status, document preparation progress and result. |
| `POST` | `/tender-check-jobs/:id/start-now` | Stop waiting for unread documents and run the check with the text available. |
| `POST` | `/tender-check-jobs/:id/cancel` | Cancel a queued or preparing job. |

A review request carries `estimateId`, `projectId`, `runId`, up to 60 unique `documentIds`, `usedDocuments` (up to 500 `{ documentId, purposes }` entries for the documents the estimate already uses), a `snapshot` of at most 200,000 characters as JSON, up to 200 `signals`, and `contractForm`. It can also carry up to 20 `importFiles`: Rates Library import files sent inline as parsed text, at most 300,000 characters in total. Citations from an import file carry `kind: "rate_import"`.

The estimate must belong to the named project: a missing estimate returns `404` and an estimate from another project returns `422`. The service allows at most 55 review jobs per organisation in a rolling 24 hours and returns `429` beyond that.

See [Tender check](https://help.stralos.app/internal/tender-check.md) for the job lifecycle, snapshot fields and review steps.

## Document response fields

The document list and document detail responses include:

* `projectId` — UUID of the owning project, or `null` for company documents.
* `partyId` — UUID of the owning company, or `null` for project documents.
* `title` — display name set by a user, or `null`. Company document lists show this field; the UI falls back to `fileName` when absent.
* `docKind` — one of `prequal`, `contract_agreement`, `day_rates`, or `other`. Always `other` for project documents.
* `issueDate` — calendar date (`YYYY-MM-DD`), or `null`.
* `reviewDate` — calendar date (`YYYY-MM-DD`), or `null`.
* `supersededAt` — ISO 8601 timestamp at which a pinned document was replaced by a newer upload, or `null` when the document is current. Only meaningful for `contract_agreement` and `day_rates` kinds.
* `relatedFolder` — the document's folder assignment as a canonical breadcrumb string (e.g. `"Tender / Drawings / Issued"`), or `null` when no folder is assigned. Use `PATCH /:id` to set or clear this field.
* `takeoffReviewStatus` — `"approved"`, `"rejected"`, or `null` (no decision yet).
* `takeoffReviewedAt` — ISO 8601 timestamp of the last decision, or `null`.
* `deletedAt` — ISO 8601 timestamp at which the document was soft-deleted, or `null` when the document is live. Only present on `GET /:id` responses; document lists never return deleted documents.
* `storeOnly`: `"store_only_type"` (emails and MS Project plans), `"too_large_to_process"` (PNG or JPEG images over 10 MB), or `null` when the document can be processed. Store-only documents can be downloaded but never processed.
* `driveLink`: for a drive-synced document, what the drive holds compared with what Stralos holds; `null` for any other document. See [Drive link object](#drive-link-object).

The takeoff panel treats pages as drawable only when `takeoffReviewStatus` is `"approved"`.

### Drive link object

| Field | Meaning |
| - | - |
| `provider` | `google_drive` or `microsoft_graph` |
| `webUrl` | The file's address in the drive, or `null`. Only `https` SharePoint and Google Drive addresses are returned |
| `hasCopy` | `true` when Stralos holds fetched bytes; `false` for a `linked` row |
| `fetchVerdict` | `processable` (can be fetched, routed and processed), `viewable` (can be fetched and downloaded, not processed), or `drive_only` |
| `driveOnlyReason` | `too_large` or `not_downloadable` when `fetchVerdict` is `drive_only`; otherwise `null` |
| `removedFromDrive` | `true` when the source file is no longer in the drive |
| `sizeBytes` | The file size last reported by the drive, or `null` |
| `ceilingBytes` | The fetch ceiling (2 GB) |
| `version` | The held copy's `{ number, label, modifiedAt }`, or `null` when no copy is held |
| `versions` | Project-scoped lists and document detail only: every version of the same drive file, as `{ documentId, label, estimateId, status }` |
| `usedBy` | Entities using the document: `{ module, entityType, entityId, label, frozen }` |
| `newerInDrive` | The drive's newer version (`versionMarker`, `versionLabel`, `fileName`, `sizeBytes`, `modifiedAt`, `modifiedBy`) with a `blockedReason` when a fetch of it was refused; otherwise `null` |
| `activeFetch` | `{ jobId, status, origin }` while a fetch job is live; otherwise `null` |

### Document kinds

| `docKind` | Owner | Pinned slot |
| - | - | - |
| `prequal` | Company only | No |
| `contract_agreement` | Company only | Yes — one current file per company |
| `day_rates` | Company only | Yes — one current file per company |
| `other` | Project or company | No |

Pinned kinds enforce a single current file per company. When a new `contract_agreement` or `day_rates` file is uploaded to the same company, the previous file's `supersededAt` is stamped in the same transaction that marks the new file `stored`. An abandoned upload (one that never reaches `stored`) leaves the previous file current.

The `includeSuperseded=true` query parameter on `GET /api/documents?partyId=` returns both current and superseded rows for a company. By default only the current row for each pinned kind is returned.

## Status model

Documents move through:

* `linked`
* `uploading`
* `scanning`
* `parsing`
* `summarising`
* `processing_areas`
* `stored`
* `processed`
* `failed`
* `infected`
* `rejected`

`linked` and `stored` are resting states. A `linked` document is a drive file Stralos has not fetched: it has a reserved storage key but no content, so it cannot be downloaded or processed until a fetch job copies and scans it. `POST /:id/open` on a `linked` document creates or joins a fetch job and returns `202`.

Delete is blocked while a document is `scanning`, `parsing`, `summarising`, or `processing_areas`. Documents in any other status, including `linked`, `uploading`, `stored`, `processed`, `failed`, `infected`, and `rejected`, can be deleted. Deletion is a soft-delete: the row is stamped with `deletedAt` and hidden from lists, but the document remains accessible by ID and all stored data is preserved.

## Areas

Supported suggestion areas are:

* `takeoff`
* `work_items`
* `project_rates`
* `programme_tender`
* `subcontracts`

## Billing access

When billing enforcement is enabled for the environment, the Documents API checks the organisation's subscription access before handling a user request. Platform service tokens bypass the check.

| Response | When |
| - | - |
| `402 billing_read_only` | The organisation has read-only access and the request is a write (anything other than `GET`, `HEAD` or `OPTIONS`) |
| `402 billing_blocked` | The payment grace period has ended. Reads are refused too |
| `401 billing_updated` | The token's billing claim no longer matches; refresh authentication and retry |
| `503 billing_unavailable` | Billing access could not be verified |

A `402` body carries `code`, `message`, and `readOnlyEndsAt` when a read-only period has an end date.

## Download gate

Downloads fail closed. A document must have a clean database scan verdict, and GuardDuty mode also checks the S3 object tag before returning a presigned download URL.

## Provider modes

The service supports provider modes for AWS and local development:

* AI: the central `api-assistant` structured-completion gateway (Gemini), or `fixture`
* OCR: `textract`, `local`, or `fixture`
* Scan: `guardduty` or `stub`
* DWG/DXF conversion: `aps` (Autodesk Platform Services) or `fixture` (deterministic, no external calls)
* Drive sync: `live` or `fixture`

The state machine, API behaviour, queues, prompts, and schemas should stay aligned across provider modes.


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