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

# Cloud-drive document sync

> Domain types and contracts for cloud-drive document sync (Google Drive and Microsoft SharePoint)

`stralos-domain` exports the authoritative shared types for the cloud-drive document sync feature. The implementation (endpoints, serialisers, worker logic) lives in `api-documents`. This page documents the domain contracts that cross service or client boundaries.

Some wire shapes on this page are no longer domain exports. `EstimateDocument`, `ProcessDocumentResponse`, `DriveVersionConflictCode`, and the document-usage event types were removed in domain 4.0.0 because no repo imported them. The shapes are still what `api-documents` serves and accepts; each consumer declares its own type.

## Overview

Cloud-drive sync lets a Stralos organisation connect a shared Google Drive or a Microsoft SharePoint library to one or more projects. The sync engine crawls the connected folder, records each file as a `linked` document row with no bytes, and keeps the links up to date as the source changes. A file's bytes are fetched when someone opens or processes it. See [Link-first drive sync](#link-first-drive-sync) below.

## Provider support

```typescript theme={null}
type DriveSourceProvider = "google_drive" | "microsoft_graph";
```

The `DriveSourceProvider` type is a closed set matching the registered provider abstraction keys.

## Drive source

A `DriveSource` represents a registered shared drive or SharePoint library.

```typescript theme={null}
type DriveSource = {
  id: EntityId;
  provider: DriveSourceProvider;
  sourceKey: string;
  displayName: string;
  config: DriveSourceConfig;
  status: DriveSourceStatus;
  statusDetail: string | null;
  organisationId: EntityId | null;
  createdByUserId: EntityId | null;
  createdAt: string;
  updatedAt: string;
};
```

`DriveSourceConfig` carries the drive-level identifiers resolved at registration: `{ driveId }` for Google Drive, `{ tenantId, siteId, driveId }` for Microsoft Graph.

`DriveSourceListItem` extends `DriveSource` with a `mappingCount` field that reflects the number of active sync connections pointing to this source, and a `proofFileName` field that carries the proof-of-control file name when the source is in `pending_proof` status (otherwise `null` or absent).

### Source lifecycle

```typescript theme={null}
type DriveSourceStatus =
  | "pending_proof"
  | "active"
  | "auth_error"
  | "error"
  | "disabled"
  | "deleted";
```

* `pending_proof` — newly registered; ownership is not yet confirmed. The source is unusable until the proof-of-control nonce file is verified.
* `active` — fully operational.
* `auth_error` / `error` — the source has degraded and can only recover via `/verify`.
* `disabled` — temporarily paused; re-enabled via a PATCH request.
* `deleted` — soft-deleted; not shown in normal listings.

The raw proof nonce is never projected as its own API or domain field. While a source is `pending_proof`, registration returns a derived `proofFileName` so the administrator can complete verification.

## Sync connection

A `SyncConnection` maps a source (or a sub-folder within it) to a project.

```typescript theme={null}
type SyncConnection = {
  id: EntityId;
  sourceId: EntityId;
  projectId: EntityId;
  folderId: string;          // empty string = whole drive/library
  displayPath: string | null;
  status: SyncConnectionStatus;
  statusDetail: string | null;
  crawlKind: SyncCrawlKind | null;
  currentRunId: EntityId | null;
  lastSyncedAt: string | null;
  createdAt: string;
  updatedAt: string;
};
```

`SyncConnectionListItem` extends `SyncConnection` with `latestRun: SyncRun | null`.

`SyncConnectionDetail` bundles the connection, its recent runs, and its notable file list:

```typescript theme={null}
type SyncConnectionDetail = {
  connection: SyncConnection;
  recentRuns: SyncRun[];
  files: SyncedFileSummary[];
};
```

### Connection lifecycle

```typescript theme={null}
type SyncConnectionStatus = "active" | "paused" | "error" | "disabled" | "deleted";
```

`crawlKind` is non-null during a scheduled full crawl. `null` indicates steady state (incremental changes only).

```typescript theme={null}
type SyncCrawlKind = "initial" | "reconciliation";
```

## Sync run

A `SyncRun` captures the visibility counters for one sync pass.

```typescript theme={null}
type SyncRun = {
  id: EntityId;
  connectionId: EntityId;
  workerStartedAt: string | null;
  startedAt: string;
  finishedAt: string | null;
  filesSeen: number;
  filesIngested: number;
  filesSuperseded: number;
  filesSkipped: number;
  filesErrored: number;
  earlyStop: boolean;
  error: string | null;
};
```

`earlyStop: true` means the run hit its per-run budget and yielded before draining the change feed. A subsequent run will resume from the saved cursor.

## Synced file status

```typescript theme={null}
type SyncedFileStatus =
  | "ingesting"
  | "synced"
  | "skipped"
  | "error"
  | "failed"
  | "removed"
  | "excluded";
```

`error` is **transient**: the file could not be placed (for example, its ancestry could not be resolved) and is retried on the next sync cycle.

`ingesting` and `failed` are copy-era states. The link-first engine no longer writes them; it re-evaluates a mapping still in either state on its next change and links the file.

`SyncedFileSummary` is the shape used in the connection-detail file list (restricted to the settled-but-notable statuses: `skipped`, `error`, `failed`, `excluded`).

## Document folder filing

When a file is ingested the sync engine resolves its source folder path and writes it to
the document's `relatedFolder` as a `/`-separated breadcrumb (scope-relative, up to
2048 characters). A confirmed scope-root or over-limit resolved path becomes `null`
(unfiled). An unresolvable path is unknown: a new document starts unfiled, while an
existing or superseded document keeps its previous filing.

The `relatedFolderSetBy` column (`'user' | 'sync' | null`) tracks which actor last wrote the folder. Sync only writes when it owns the value: the marker is `sync`, or both the marker and `relatedFolder` are `null`. A user-set folder (via `PATCH /api/documents/:id`) is never overwritten by sync — even when the source file moves.

See [Cloud drive sync](https://help.stralos.app/internal/drive-sync.md) for the folder resolution algorithm and the post-deploy backfill procedure.

## Document routing and the nullable estimateId

A document's `estimateId` is `EntityId | null`. A synced or project-uploaded document is project-scoped but not yet routed into an estimate — `null` means "not routed". Consumers must handle the unrouted case before reading estimate-specific fields.

### Routing a document

```typescript theme={null}
// POST /documents/:id/route
type RouteDocumentRequest = {
  estimateId: EntityId | null;  // null = unroute
  targetAreas?: AreaKey[];
  process?: boolean;
};

type RouteDocumentResponse = {
  routed: boolean;
  processing: RouteProcessingResult;
};
```

`targetAreas` set implies `process: true`. Processing is skipped (with a `RouteProcessingSkipReason`) when the document is not in the `stored` resting state or processing was not requested.

For a `linked` document, `/route` with processing requested creates or joins a `first_fetch` job and reports `fetching` with its `jobId`. The job processes the document once the fetched copy scans clean.

```typescript theme={null}
type RouteProcessingSkipReason = "not_stored" | "not_requested" | "fetching";

type RouteProcessingResult =
  | { started: true }
  | { started: false; reason: Exclude<RouteProcessingSkipReason, "fetching"> }
  | { started: false; reason: "fetching"; jobId: EntityId };
```

`api-documents` also answers `reason: "store_only"` when a store-only document is routed with processing requested. That value is not yet in the domain union.

### Processing a document

The `POST /documents/:id/process` response has an additional `already_processing` skip reason that is not present on `RouteProcessingResult`, and a `fetching` arm for linked documents:

```typescript theme={null}
// POST /documents/:id/process response. Declare it locally; it is not a domain export.
type ProcessDocumentResponse =
  | { started: true }
  | { started: false; reason: "already_processing" }
  | { started: false; reason: "fetching"; job: DriveFetchJob };
```

`already_processing` is the idempotent lost-response retry case: an earlier `/process` call already started the pipeline, so the retry settles 200 rather than a 409 conflict. Documents that are genuinely un-processable (not routed, not scan-clean) still surface as a 409 `ConflictError`.

`fetching` (HTTP 202) means the document is a `linked` drive file. The endpoint created or joined a `first_fetch` job that processes the document once the fetched copy scans clean.

## Document sync provenance

The `sync` key in a document's `docMetadata` jsonb records the sync engine's provenance for synced files. It is owned exclusively by the sync engine and always written complete.

```typescript theme={null}
type DocMetadataSync = {
  provider: DriveSourceProvider;
  connectionId: EntityId;
  externalFileId: string;
  externalUrl?: string;
  folderPath?: string;
  versionMarker: string;
  syncedAt: string;
  modifiedAt?: string;         // the drive's modified time for this row's version
  modifiedBy?: string;         // the drive's modifier display name for this row's version
  sizeBytes?: number;          // the size of this row's version
  exportedFrom?: string;       // set when a Google-native file was exported (e.g. Doc → PDF)
  removedFromSource?: boolean; // set when the source file is gone; cleared on restore
  removedAt?: string;
};
```

`modifiedAt`, `modifiedBy` and `sizeBytes` are the "used" half of the version delta. They are absent on rows linked or fetched before link-first; treat absence as unknown when building version labels or deltas.

`DocMetadata` uses an index signature for the other pipeline-owned keys (summaries, thumbnails, takeoff pages) so each stage can own its own keys without enumerating them all in one type.

## Drive source registration and connection creation

### Register a drive source

```typescript theme={null}
// POST /drive-sources
type RegisterDriveSourceRequest = {
  provider: DriveSourceProvider;
  sourceUrl: string;
};

type RegisterDriveSourceResponse = {
  sourceId: EntityId;
  source: DriveSource;
  proofFileName?: string;  // present only while status is pending_proof
};
```

The endpoint is idempotent: registering the same source URL returns the existing live source. A soft-deleted source is reactivated in `pending_proof` with a fresh proof file name. A disabled source must be enabled with `PATCH /drive-sources/:id`.

### Verify a drive source

```typescript theme={null}
// POST /drive-sources/:id/verify
type VerifyDriveSourceResponse = {
  source: DriveSource;
};
```

Used for initial proof-of-control confirmation and for recovery from `auth_error` or `error` states.

### Create a sync connection

```typescript theme={null}
// POST /sync-connections
type CreateSyncConnectionRequest = {
  sourceId: EntityId;
  projectId: EntityId;
  folderId?: string;  // omitted or '' = whole drive/library
};
```

The response is a three-way discriminated union reflecting the three possible settled outcomes:

```typescript theme={null}
type CreateSyncConnectionResponse =
  | { connection: SyncConnection; created: true; reactivated?: never }     // 201 fresh insert
  | { connection: SyncConnection; created: false; reactivated?: never }    // 200 idempotent no-op
  | { connection: SyncConnection; reactivated: true; created?: never };    // 200 reactivated
```

Narrow on `created` and `reactivated` to distinguish the three arms. Neither flag being true means the connection already existed and was already live.

## Folder browsing

The mapping picker uses `FolderEntry` items returned by `GET /drive-sources/:id/folders`.

```typescript theme={null}
type FolderEntry = {
  folderId: string;
  name: string;
  parentId?: string;
};

type FolderListResponse = {
  items: FolderEntry[];
};
```

## Link-first drive sync

`stralos-domain` carries the link-first wire types. Instead of copying each drive file into S3 during a sync run, the engine writes a `linked` row with no bytes, and a user's open fetches the file. For the engine and worker design, see [Cloud drive sync](https://help.stralos.app/internal/drive-sync.md#link-first-sync).

### Document status: `linked`

```typescript theme={null}
type DocumentStatus =
  | "linked"
  | "uploading" | "scanning" | "parsing" | "summarising"
  | "processing_areas" | "stored" | "processed"
  | "failed" | "infected" | "rejected";
```

`linked` is a resting status like `stored`. The row has a reserved S3 key but no object, so it is settled (stop polling) and every serve path fails closed until a fetch job completes. A consumer with an exhaustive switch over its own status subset adds `linked` when it adopts this type.

### Drive link read model

`driveLink` appears on document list and detail rows. It is `null` for a document that did not come from a drive.

```typescript theme={null}
type DriveLink = {
  provider: DriveSourceProvider;
  webUrl: string | null;
  hasCopy: boolean;
  fetchVerdict: "processable" | "viewable" | "drive_only";
  driveOnlyReason: "too_large" | "not_downloadable" | null;
  removedFromDrive: boolean;
  sizeBytes: number | null;
  ceilingBytes: number;
  version: { number: number; label: string; modifiedAt: string | null } | null;
  versions?: Array<{
    documentId: EntityId;
    label: string;
    estimateId: EntityId | null;
    status: DocumentStatus;
  }>;
  usedBy: EntityRef[];
  newerInDrive:
    | (DriveVersion & { blockedReason: "too_large" | "not_downloadable" | "scan_threat" | null })
    | null;
  activeFetch: {
    jobId: EntityId;
    status: DriveFetchJobStatus;
    origin: DriveFetchJobOrigin;
  } | null;
};
```

* `hasCopy`: Stralos holds fetched bytes for this row.
* `fetchVerdict`: `processable` can be fetched, routed and processed; `viewable` can be fetched and downloaded but `/process` answers 409; `drive_only` is above the 2 GB ceiling or not downloadable and opens only in the drive.
* `version`: the held copy's version, with a label such as `Version 2 · modified 12 Sep`. `null` for a row that holds no copy.
* `versions`: present on project-scoped document lists and on document detail, one entry per version of the same drive file.
* `usedBy`: the entities using the document, from the usage registry (`EntityRef` carries `module`, `entityType`, `entityId`, `label` and `frozen`).
* `newerInDrive`: the drive's latest version when it is newer than this row's. `blockedReason` is set when a fetch of that version was refused.
* `activeFetch`: set while a fetch job is live.

### Fetch jobs

```typescript theme={null}
type DriveFetchJobKind = "first_fetch" | "refetch" | "new_version";
type DriveFetchJobOrigin = "user" | "prefetch" | "variation";
type DriveFetchJobStatus = "queued" | "fetching" | "storing" | "scanning" | "ready" | "failed";
type DriveFetchJobStep = 1 | 2 | 3 | 4; // 1 queued/fetching, 2 storing, 3 scanning, 4 ready

type DriveFetchJob = {
  id: EntityId;
  documentId: EntityId;
  resultDocumentId: EntityId | null;
  kind: DriveFetchJobKind;
  origin: DriveFetchJobOrigin;
  status: DriveFetchJobStatus;
  step: DriveFetchJobStep;
  failureReason: DriveFetchFailureReason | null;
  processingOutcome: DriveFetchProcessingOutcome | null;
  bytesTotal: number | null;
  bytesDone: number;
  provider: DriveSourceProvider;
  fileName: string;
  webUrl: string | null;
  versionLabel: string | null; // set when the job is ready
  requestedAt: string;
  updatedAt: string;
};

type DriveFetchFailureReason =
  | "too_large" | "not_downloadable" | "access_revoked" | "deleted_in_drive"
  | "scan_threat" | "scan_error" | "changed_during_fetch" | "provider_error"
  | "timeout" | "sync_disabled";

type DriveFetchProcessingOutcome =
  | "started" | "store_only" | "not_processable" | "awaiting_conversion" | "not_routed";
```

`first_fetch` fills the linked row as Version 1, `refetch` refills an evicted row at its own version, and `new_version` adds Version N+1 as a new row beside the old one. `resultDocumentId` is the row the bytes land in and is `null` until the worker chooses it. `processingOutcome` records what requested processing did once the fetched copy scanned clean; `awaiting_conversion` is a DWG waiting for its PDF.

`GET /documents/drive-fetches/:jobId` returns `{ job: DriveFetchJob; download?: DocumentDownload }`. `download` (`{ url, expiresAt }`) is present only when the job is `ready`.

### Opening a document

```typescript theme={null}
type OpenDocumentRequest = {
  version?: "auto" | "keep" | "latest"; // defaults to "auto"
  entityType?: DocumentUsageEntityType;  // with entityId: the entity the open came from
  entityId?: EntityId;
};

type OpenDocumentResponse =
  | { state: "ready"; documentId: EntityId; versionLabel: string | null;
      download: DocumentDownload; newerInDrive: boolean;
      newerVersion: DocumentVersionRef | null; frozenUse?: boolean;
      removedFromDrive?: boolean; versionCheck: "live" | "unavailable" | "not_applicable";
      delta: DriveVersionDelta | null }
  | { state: "fetching"; job: DriveFetchJob; webUrl: string | null }
  | { state: "decision_required"; documentId: EntityId;
      used: DriveVersion; latest: DriveVersion;
      entities: EntityRef[]; usages: DocumentEntityUsage[];
      processedAreas: AreaKey[]; webUrl: string | null }
  | { state: "drive_only"; reason: "too_large" | "not_downloadable";
      webUrl: string | null; sizeBytes: number | null; ceilingBytes: number }
  | { state: "unavailable";
      reason: "deleted_in_drive" | "access_revoked" | "scan_threat" | "sync_disabled";
      webUrl: string | null };
```

Discriminate on `state`. `fetching` is HTTP 202 and the rest are 200. `decision_required` lists the non-frozen entities that use the document and must choose to keep or sync. `versionCheck: "unavailable"` means the live version check timed out or failed and Stralos served its copy anyway.

### Version deltas and drive-version actions

```typescript theme={null}
type DriveVersion = {
  versionMarker: string;
  versionLabel: string | null; // null for a drive version Stralos does not hold yet
  fileName: string;
  sizeBytes: number | null;
  modifiedAt: string | null;
  modifiedBy: string | null;
};

type DriveVersionDelta = { used: DriveVersion; drive: DriveVersion };

// POST /documents/:id/drive-version (202 { state: "fetching"; job: DriveFetchJob })
type DriveVersionRequest =
  | { action: "sync"; latestVersionMarker: string;
      entityType: DocumentUsageEntityType; entityId: EntityId;
      processAreas?: AreaKey[] | null }
  | { action: "fetch_version"; latestVersionMarker: string;
      entityType?: DocumentUsageEntityType; entityId?: EntityId };
```

`/drive-version` answers 409 with `version_changed`, `no_newer_version`, `entity_frozen`, or `document_superseded`. `sync` adds the drive's newer version beside the one the entity used. The entity is required and must not be frozen, and an estimate entity gets the new version routed into it. `fetch_version` fetches the version without routing it. A `409 version_changed` carries `latest`, and `document_superseded` carries `currentDocumentId`.

### Prefetch

`POST /documents/prefetch` takes `{ entityType, entityId }` and queues fetch jobs for the entity's linked documents when the entity is first opened. It returns `202 { queued, joined, skipped: [{ documentId, reason }] }`. Limits are in `src/sync/linkFirstPolicy.ts`:

* **Per-entity cooldown (30 minutes).** A repeat call for the same entity inside the window returns `202 { queued: 0, joined: 0, skipped: [] }`. It records no new `drive_prefetch_requested` event, so the window does not slide.
* **Organisation budget (100 jobs per hour).** When the budget is spent, the call returns `429 prefetch_throttled` with `retryAfterSeconds`. Only this limit returns `429`.
* **Per call (25 jobs).** Documents beyond the per-call or remaining hourly budget are skipped with `prefetch_limit`.

### Usage registry

```typescript theme={null}
type DocumentUsageModule = "estimate" | "documents" | (string & {});
type DocumentUsageEntityType = "estimate" | (string & {});
```

A `DocumentUsagesChanged` v1 event detail carries `module`, `entityType`, `entityId`, `label` (`string | null`), `projectId` (required on the event), `frozen`, `usageVersion` (monotonic per entity), and `usages`: an array of `{ documentId, purpose, count }`, where `purpose` is a `DocumentUsagePurpose` (for example `"routed"`, `"takeoff_measurement"`, or `"work_item_source"`). api-estimate declares the publisher side in `src/events/documentUsages.ts`; api-documents validates the set with `usageSetSchema` in `src/usage/registry.ts`.

`usages` is the full current set for the entity; a document absent from it is no longer used. The consumer applies an event only when `usageVersion` is above the value it holds, then replaces that module's rows for the entity. On the estate bus the detail also carries the envelope fields `version: 1`, `eventId`, `occurredAt`, `orgId`, and optional `actorId` and `correlationId`.

`PUT /documents/usages/:module/:entityType/:entityId` accepts the same body for a module without a bus publisher, under a `service:<module>` token only. There, `label` and `projectId` are optional and nullable. It returns `{ applied: boolean }`.

An acknowledgement records a per-entity decision about one flag:

```typescript theme={null}
type UsageAcknowledgementKind =
  | "newer_version_kept"   // keep a drive version out (at versionMarker)
  | "removed_from_drive"   // hide a removed-file banner line
  | "older_version_kept";  // mute "source has Version N+1" item flags
```

`POST /documents/usages/:entityType/:entityId/acknowledgements` takes `{ documentId, kind, versionMarker? }` and returns 404 `no_such_usage` or 409 `version_changed` on conflict. `DELETE …/acknowledgements/:documentId/:kind` (204) shows the flag again.

### Usage changes

`GET /documents/usages/:entityType/:entityId/changes` is the generic module contract. The estimate banner, submit check, item flags and Variations panel read it.

```typescript theme={null}
type DocumentUsageChangesResponse = {
  entity: EntityRef | null; // null when nothing is registered yet
  sources: DocumentUsageSource[];
  counts: { changed: number; removed: number; promptWorthy: number;
            flagged: number; possibleVariations: number };
};
```

Each `DocumentUsageSource` carries the document's `driveLink`, the `delta`, any `newerVersion` already in Stralos, `removedFromDrive`, the entity's `acknowledged` kinds, and three flags:

* `promptWorthy`: the entity is not frozen, the source is newer in the drive or has a newer Stralos version, and it has not been kept out.
* `flagItems`: the module should flag the items that came from this document.
* `possibleVariation`: the entity is frozen and the source changed or was removed.

### Processing recommendations

`POST /documents/projects/:projectId/processing-recommendations` asks the AI to suggest which documents to process, from file names and folder paths only. Nothing is ticked or processed until the user confirms through `/route`.

```typescript theme={null}
type ProcessingRecommendation = {
  documentId: EntityId;
  fileName: string;
  folderPath: string | null;
  reason: string;
  suggestedAreas: AreaKey[];
};

type ProcessingRecommendationsResponse = {
  recommendations: ProcessingRecommendation[];
};
```


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