> ## Documentation Index
> Fetch the complete documentation index at: https://docs.findmydata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Api reference

# HTTP API reference

This document enumerates every HTTP endpoint exposed by the Find My Data server (release 26.7.15.0), for integrators building against the API and reviewers auditing its authorization surface. Every path, capability string, and field name below is taken directly from the route modules mounted in `packages/server/src/app.ts`; the authorization and error-envelope behavior is defined in `packages/server/src/kernel/http.ts` and `packages/server/src/kernel/errors.ts`. Role-to-capability grants are documented in the [permissions manifest](permissions-manifest.md); the security rationale is in the [threat model](threat-model.md).

## Conventions

* All endpoints live under `/api/`. Requests and responses are JSON unless noted (the Graph webhook validation handshake responds `text/plain`).
* Request bodies are validated with Zod schemas; a failed parse returns `400 validation_failed` with per-field issues (see [Error envelope](#error-envelope)).
* An optional `x-fmd-correlation` request header sets the correlation id for the request; otherwise one is generated. Responses carry an `x-fmd-auth-mode` header (`dev` or `entra`) — except the `csrf_token_invalid` rejection, which is returned before the header is set.
* List endpoints are bounded (e.g. assets 200 rows, findings 200, audit 200, scans 50, analyst history 25); there is no cursor pagination in this release.

### Capability column legend

| Value                     | Meaning                                                                                                                                                       |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `capability.string`       | The exact string passed to `requireCapability` (`kernel/authz.ts`). Deny by default.                                                                          |
| session only              | Requires an authenticated session but no specific capability; per-row visibility is enforced in the handler/repository.                                       |
| none (public)             | No session required.                                                                                                                                          |
| UNAUTHENTICATED (webhook) | Deliberately unauthenticated receiver; trust comes from the Graph validation-token handshake and a per-subscription `clientState` HMAC, never from a session. |
| dev-mode only             | Endpoint exists only in development configuration; any other configuration returns `404` (the endpoint is hidden, not merely forbidden).                      |

## Authentication model

### Session cookie

Authentication is a signed session cookie named `fmd_session` (`SESSION_COOKIE` in `kernel/http.ts`): `httpOnly`, `SameSite=Lax`, `Secure` when `FMD_ENV=production`, path `/`. The cookie value is an HMAC-signed session id; the server resolves it to a session row and loads the authorization context (tenant, principal, roles, owned domains) per request. Client-supplied identity fields are never trusted.

Two auth modes exist ([ADR-0004](adr/ADR-0004-authentication-dev-mode-and-entra.md)):

* **Dev mode** (`FMD_AUTH_MODE=dev`, development only). Login flow:

  1. `GET /api/auth/dev/personas` — list seeded principals with roles.
  2. `POST /api/auth/dev/login` with `{ "principalId": "..." }` — sets the cookie and returns `{ ok, csrfToken, authMode }`.

  Production refuses dev mode twice: the process fails to boot with `FMD_AUTH_MODE=dev` when `FMD_ENV=production` (`kernel/config.ts`), and the request middleware rejects any surviving dev-mode session at request time (`kernel/http.ts`).
* **Entra mode** (`FMD_AUTH_MODE=entra`) — **not built**. `GET /api/auth/entra/login` returns an explicit `501 not_configured` pointing at the integration seam; it is never a silent fallback.

### CSRF

Every mutating request (`POST`, `PUT`, `PATCH`, `DELETE`) made **with a session** must send the session's CSRF token in the `x-fmd-csrf` header. The token is returned by `POST /api/auth/dev/login` and by `GET /api/auth/me`. A missing or wrong token returns `403 {"error":"csrf_token_invalid"}`. Requests without a resolved session skip the CSRF check — this is what allows the unauthenticated Graph webhooks and the dev login itself to POST.

### 401 / 403 / 404 semantics

| Status                        | When                                                                                                                                                                                                                                                         |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `401 authentication_required` | No valid session on an endpoint that requires one.                                                                                                                                                                                                           |
| `403 not_permitted`           | Authenticated but missing the required capability. Every denial is appended to the audit log as `authorization_denied` with the capability as the policy decision.                                                                                           |
| `403 csrf_token_invalid`      | Mutation without a valid `x-fmd-csrf` header.                                                                                                                                                                                                                |
| `404 not_found`               | Resource missing — **or** the resource exists but belongs to another tenant or a business domain the caller cannot see. Cross-scope probes get 404, not 403, so existence never leaks (`NotFoundError` in `kernel/errors.ts`; tested in the security suite). |

Authorization is capability-based, not role-based: handlers call `requireCapability(auth, "...")`, and capabilities are granted per role at either **tenant** scope or **domain** scope (resolved against active ownership assignments). Domain-scoped list endpoints additionally apply `domainScopeFilter` so results are constrained to the caller's owned domains. See the [permissions manifest](permissions-manifest.md) for the full role/capability matrix.

## Error envelope

The central error handler (`kernel/http.ts`) maps typed errors (`kernel/errors.ts`) to a uniform envelope:

```json theme={null}
{ "error": "<code>", "message": "<human-readable>" }
```

Variants:

* Zod validation failures: `{ "error": "validation_failed", "issues": [{ "path": "field.path", "message": "..." }] }` (400).
* CSRF failures: `{ "error": "csrf_token_invalid" }` (403, no `message`).
* Unhandled errors: `{ "error": "internal_error", "correlationId": "..." }` (500) — no stack or details leak.

| Code                      | HTTP | Source                                                                                                                                                                                                                |
| ------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_failed`       | 400  | `ValidationError` / Zod parse failure                                                                                                                                                                                 |
| `authentication_required` | 401  | `AuthnError`                                                                                                                                                                                                          |
| `not_permitted`           | 403  | `AuthzError` (audited)                                                                                                                                                                                                |
| `csrf_token_invalid`      | 403  | CSRF middleware                                                                                                                                                                                                       |
| `not_found`               | 404  | `NotFoundError` (incl. cross-tenant/cross-domain probes)                                                                                                                                                              |
| `conflict`                | 409  | `ConflictError` (e.g. state-machine violations)                                                                                                                                                                       |
| `blocked_capability`      | 422  | `BlockedError` — the operation is implemented but its live integration is feature-gated off or its credentials do not resolve. The honest "built but not enabled here" signal, distinct from other 4xx caller errors. |
| `internal_error`          | 500  | Unhandled exception                                                                                                                                                                                                   |
| `not_configured`          | 501  | Entra login seam only (literal response, not an `AppError`)                                                                                                                                                           |

## Endpoint reference

83 endpoints across 14 route modules, in mount order from `packages/server/src/app.ts`.

### Health (`routes/health.ts`)

Liveness and honest readiness. No authentication.

| Method | Path          | Capability    | Purpose                                                                              |
| ------ | ------------- | ------------- | ------------------------------------------------------------------------------------ |
| GET    | `/api/health` | none (public) | Liveness: `{ "status": "ok" }`.                                                      |
| GET    | `/api/ready`  | none (public) | Readiness: DB check plus per-integration mode report. `503` when the DB check fails. |

`GET /api/ready` returns `{ status, dbOk, env, integrations }` where each integration (`connector`, `aiProvider`, `entraSignIn`, `purviewLabelRead`, `purviewLabelWrite`, `changeNotifications`, `teamsConnector`, `exchangeConnector`) reports one of:

* `mock` — the deterministic fixture path is in use,
* `live` — feature enabled **and** its secret reference actually resolves to a value,
* `blocked` — feature enabled but credentials do not resolve (never reported as `live`),
* `disabled` — feature turned off.

Two integrations depart from the general `blocked`/`disabled` split: `purviewLabelRead` and `purviewLabelWrite` report `blocked` (never `disabled`) whenever they are not fully live — including when their feature flag is simply off — and `entraSignIn` reports `blocked` whenever `FMD_AUTH_MODE=entra` because the OIDC flow is not built (`disabled` otherwise). `changeNotifications` additionally requires a public webhook URL (`FMD_WEBHOOK_PUBLIC_URL`) to report `live`.

### Auth (`identity/auth-routes.ts`)

| Method | Path                     | Capability                     | Purpose                                                                                                                                                                     |
| ------ | ------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/api/auth/mode`         | none (public)                  | Report `{ env, authMode, devBannerRequired }` so the UI can show the dev-identity banner.                                                                                   |
| GET    | `/api/auth/dev/personas` | dev-mode only                  | List seeded active user principals with roles for the login picker. `404` outside dev env + dev auth mode.                                                                  |
| POST   | `/api/auth/dev/login`    | dev-mode only                  | Start a session as a seeded principal. Body `{ principalId }`; returns `{ ok, csrfToken, authMode }` and sets `fmd_session`. Audited as `dev_login`. `404` outside dev.     |
| POST   | `/api/auth/logout`       | none (public)                  | Revoke the session (if any) and clear the cookie; returns `{ ok: true }` even without a session (CSRF header required while logged in). Audited when a session was revoked. |
| GET    | `/api/auth/me`           | session only                   | Current principal: `{ principalId, tenantId, authMode, displayName, title, department, roles, ownedDomains, csrfToken }`. `401` without a session.                          |
| GET    | `/api/auth/entra/login`  | none (public; entra mode only) | **Not built**: returns `501 not_configured` describing the Entra OIDC seam. `404` unless `FMD_AUTH_MODE=entra`.                                                             |

### Governance (`governance/routes.ts`)

Onboarding, taxonomy lifecycle (draft → human review → approval), and ownership assignment. AI-generated taxonomy objects are always drafts pending human review.

| Method | Path                                            | Capability                    | Purpose                                                                                                                                                                        |
| ------ | ----------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GET    | `/api/onboarding/profile`                       | `tenant.profile.read`         | Read the organization profile.                                                                                                                                                 |
| POST   | `/api/onboarding/profile`                       | `tenant.profile.write`        | Upsert the org profile. Body: `name`, `industries[]`, `geographies[]`, `description`. Returns `{ profileId }`.                                                                 |
| POST   | `/api/onboarding/policy-documents`              | `governance.policy.ingest`    | Upload a policy document for section parsing + citations. Body: `title`, `filename`, `text`.                                                                                   |
| GET    | `/api/onboarding/policy-documents`              | `governance.policy.read`      | List uploaded policy documents.                                                                                                                                                |
| POST   | `/api/onboarding/generate-taxonomy`             | `governance.taxonomy.propose` | Generate a draft taxonomy via the AI gateway ([ADR-0005](adr/ADR-0005-ai-provider-gateway.md)). Response includes `provider`, `model`, and a note that all objects are drafts. |
| GET    | `/api/governance/taxonomy`                      | `governance.taxonomy.read`    | List domains + information types; domain-scoped callers see only their domains.                                                                                                |
| PATCH  | `/api/governance/domains/:id`                   | `governance.taxonomy.admin`   | Edit a domain. Body (all optional): `name`, `description`, `inclusion`, `exclusion`.                                                                                           |
| POST   | `/api/governance/domains/:id/approve`           | `governance.taxonomy.approve` | Approve a draft domain.                                                                                                                                                        |
| PATCH  | `/api/governance/information-types/:id`         | `governance.taxonomy.admin`   | Edit an information type. Body (all optional): `name`, `definition`, `sensitivityRationale`, `expectedLocationNote`.                                                           |
| POST   | `/api/governance/information-types/:id/approve` | `governance.taxonomy.approve` | Approve a draft information type.                                                                                                                                              |
| GET    | `/api/governance/owner-suggestions`             | `governance.owner.assign`     | Evidence-ranked owner suggestions for `?domainId=`.                                                                                                                            |
| POST   | `/api/governance/owners`                        | `governance.owner.assign`     | Assign an owner. Body: `principalId`, `domainId`, `kind` (`owner`\|`delegate`\|`steward`, default `owner`), `attestationDays?` (1–730). Returns `{ assignmentId }`.            |
| GET    | `/api/governance/owners`                        | `governance.taxonomy.read`    | List owners, optionally `?domainId=`. A domain-scoped reader asking about a foreign domain gets an empty list.                                                                 |
| GET    | `/api/governance/labels`                        | `governance.taxonomy.read`    | Sensitivity-label catalog.                                                                                                                                                     |

### Scan (`scan/routes.ts`)

Connector registration and durable scan campaigns ([ADR-0007](adr/ADR-0007-scan-pipeline-and-stage-caching.md), [ADR-0008](adr/ADR-0008-connector-contract-and-mock-m365.md)). The mock M365 connector is the default/CI path; the live Graph connector is implemented and was validated **read-only** against the live CDX tenant ([ADR-0013](adr/ADR-0013-live-graph-connector.md), [CDX runbook](cdx-test-runbook.md)).

Scope visibility: tenant-scoped readers (admin/auditor) see all scopes; a domain-scoped owner sees scopes governed by their domains or holding at least one asset classified into them.

| Method | Path                           | Capability                                               | Purpose                                                                                                                                                                                                                                                                                                               |
| ------ | ------------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST   | `/api/sources/connect`         | `connector.configure`                                    | Create/reuse the M365 connector instance and register its discovered scopes. Returns `{ connectorInstanceId, scopesRegistered, mode }`. Audited.                                                                                                                                                                      |
| GET    | `/api/sources`                 | `scan.job.read`                                          | List visible source scopes with coverage state, asset count, and delta-cursor status.                                                                                                                                                                                                                                 |
| POST   | `/api/sources/:id/expectation` | `governance.taxonomy.admin`                              | Set/clear the governed location expectation. Body: `{ domainId: string \| null }`. `404` for unknown scope. Audited.                                                                                                                                                                                                  |
| POST   | `/api/scans`                   | `scan.job.create`                                        | Create a scan campaign. Body: `{ scopeId, kind: "inventory" \| "delta" }`. Returns `{ campaignId }`.                                                                                                                                                                                                                  |
| POST   | `/api/scans/:id/:action`       | `scan.job.pause` / `scan.job.resume` / `scan.job.cancel` | Transition a campaign; `:action` ∈ `pause`\|`resume`\|`cancel`, and the capability checked is `scan.job.<action>`.                                                                                                                                                                                                    |
| GET    | `/api/scans`                   | `scan.job.read`                                          | List campaigns (50 most recent) for visible scopes. Tenant-wide model-rescore campaigns (no scope) are visible to tenant-scoped readers only.                                                                                                                                                                         |
| GET    | `/api/scans/:id`               | `scan.job.read`                                          | Campaign status/progress. Domain-scoped callers get `404` for campaigns outside their visible scopes (no existence leak).                                                                                                                                                                                             |
| POST   | `/api/dev/fixture/mutate`      | dev-mode only + `connector.configure`                    | Demo change-sequence controls against the **mock tenant only** (`404` unless dev env and `connectorMode=mock`). Body: `scopeExternalId`, `action` ∈ `add`\|`edit`\|`rename`\|`overshare`\|`restrict`\|`set_label`\|`clear_label`\|`delete`\|`request_resync`, plus optional `fileId`, `content`, `name`, `labelGuid`. |

### Models (`routes/models.ts`)

Governed dataset curation and model-release lifecycle with separation of duties (approver ≠ proposer, enforced in `models/pipeline.ts`). This loop ran end-to-end on live CDX data.

| Method | Path                                        | Capability                 | Purpose                                                                                                                                                                                                                                          |
| ------ | ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| POST   | `/api/models/rescore`                       | `model.release.approve`    | Release a new ensemble version and re-score retained features — no source I/O.                                                                                                                                                                   |
| GET    | `/api/models/releases`                      | `governance.taxonomy.read` | List ensemble and detector releases.                                                                                                                                                                                                             |
| GET    | `/api/models/dataset-candidates`            | `model.dataset.curate`     | List dataset candidates, optional `?status=` (`pending`\|`approved`\|`rejected`).                                                                                                                                                                |
| POST   | `/api/models/dataset-candidates/:id/decide` | `model.dataset.curate`     | Curate a candidate. Body: `{ decision: "approved" \| "rejected" }`.                                                                                                                                                                              |
| GET    | `/api/models/datasets`                      | `model.dataset.curate`     | List dataset snapshots.                                                                                                                                                                                                                          |
| POST   | `/api/models/datasets`                      | `model.dataset.curate`     | Create a consented, cluster-aware train/eval snapshot. Body: `name`, `description?`, `consentScope?` (`tenant_internal`\|`vendor_shareable`), `evalFraction?` (0.1–0.9).                                                                         |
| GET    | `/api/models/governed-releases`             | `governance.taxonomy.read` | List governed model releases with lifecycle state.                                                                                                                                                                                               |
| POST   | `/api/models/releases/propose`              | `model.release.propose`    | Propose a release. Body: `configPatch` (optional threshold/boost overrides: `candidateThreshold`, `reviewLowThreshold`, `reviewHighThreshold`, `nearDupConfirmedBoost`, `exactDupConfirmedBoost`, each 0–1), `datasetSnapshotId?`, `rationale?`. |
| POST   | `/api/models/releases/:id/evaluate`         | `model.release.propose`    | Evaluate candidate vs. current on held-out labels (precision/recall/F1).                                                                                                                                                                         |
| POST   | `/api/models/releases/:id/approve`          | `model.release.approve`    | Approve (separation of duties: self-approval is rejected).                                                                                                                                                                                       |
| POST   | `/api/models/releases/:id/reject`           | `model.release.approve`    | Reject a proposed release.                                                                                                                                                                                                                       |
| POST   | `/api/models/releases/:id/promote`          | `model.release.approve`    | Promote: bump the active ensemble and trigger a source-free rescore.                                                                                                                                                                             |
| POST   | `/api/models/releases/:id/rollback`         | `model.release.approve`    | Roll back to the prior release.                                                                                                                                                                                                                  |

### Review (`review/routes.ts`)

The "Is this yours?" owner review queue. Decisions create linked `OwnerConfirmed` assertions — the original inference is never destroyed — and propagate to cluster/container neighbors without auto-confirming.

| Method | Path                                  | Capability      | Purpose                                                                                                                                                                                                                                                                           |
| ------ | ------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/api/review/queue`                   | `review.read`   | List pending candidates, optional `?domainId=`; domain-scoped to the caller.                                                                                                                                                                                                      |
| GET    | `/api/review/candidates/:id`          | `review.read`   | Candidate detail with evidence (excerpts are capability-gated and audited; cross-domain probes `404`).                                                                                                                                                                            |
| POST   | `/api/review/candidates/:id/decision` | `review.decide` | Record a decision. Body: `decision` ∈ `mine`\|`mine_different_type`\|`another_domain`\|`mine_not_sensitive`\|`not_a_match`\|`need_more_evidence` (from `@fmd/shared` `REVIEW_DECISIONS`), plus `correctedInformationTypeId?`, `routedDomainId?`, `note?` and `trainingEligible?`. |

### Analyst (`analyst/routes.ts`)

Typed, scoped query answering over the search projection ([ADR-0009](adr/ADR-0009-analyst-typed-plan.md)). Plans — whether deterministic or AI-proposed — are validated identically, and tenant/domain predicates are injected server-side; a plan cannot widen its scope.

| Method | Path                   | Capability      | Purpose                                                                                                                                                                                                                                                                                        |
| ------ | ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST   | `/api/analyst/query`   | `analyst.query` | Answer a question. Body: `question` (3–2000 chars), `planner` (`deterministic` default, or `ai`). With `planner:"ai"` the provider sees only scope-visible display names delimited as untrusted data. Returns `{ answer }` with interpretation, scope, evidence rows, coverage, and freshness. |
| GET    | `/api/analyst/history` | `analyst.query` | The caller's own last 25 queries (principal-scoped, not tenant-wide).                                                                                                                                                                                                                          |

### Actions (`actions/routes.ts`)

Approval-gated remediation state machine ([ADR-0010](adr/ADR-0010-remediation-state-machine.md)): draft (with preview + justification) → submit → approve → execute → verify from source. `internal_task` runs fully in-product. `purview_label_set` / `purview_label_remove` are **built and offline/mock-validated; the live Graph write path is feature-gated** (`FMD_FEATURE_PURVIEW_LABEL_WRITE` + a separate remediation identity) and refuses honestly when unconfigured: the draft is recorded with `status: "blocked"` and a `blockedReason`, and submitting a blocked action returns `409 conflict` (a live connector whose base Graph credentials do not resolve fails earlier with `422 blocked_capability`). Live label writes have **not** been exercised against a real tenant.

| Method | Path                         | Capability       | Purpose                                                                                                                                                                                                                                                                                                    |
| ------ | ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST   | `/api/actions`               | `action.draft`   | Draft an action with preview. Body: `kind` ∈ `internal_task`\|`purview_label_set`\|`purview_label_remove`, `targetAssetId`, `justification` (required, min 10 chars), plus `findingId?`, `desiredLabelGuid?` (required for `purview_label_set`), `taskTitle?` (required for `internal_task`), `taskNote?`. |
| GET    | `/api/actions`               | session only     | List actions (100 most recent). Callers with `action.approve` or `audit.read` see all; others see only actions in their owned domains (enforced in `listActions`).                                                                                                                                         |
| POST   | `/api/actions/:id/submit`    | `action.submit`  | Submit a validated draft for approval.                                                                                                                                                                                                                                                                     |
| POST   | `/api/actions/:id/approval`  | `action.approve` | Approve or reject. Body: `{ decision: "approve" \| "reject", reason? }` (`reason` defaults to empty). Separation of duties: a requester cannot approve their own action.                                                                                                                                   |
| POST   | `/api/actions/:id/repreview` | `action.draft`   | Re-preview after source drift invalidates the captured state.                                                                                                                                                                                                                                              |

### Read models (`routes/read-models.ts`)

Owner-facing read surface. Every query is tenant-scoped; domain-scoped capabilities filter to owned domains. Evidence levels are tiered (metadata \< summary \< excerpt); excerpt reads require `asset.evidence.excerpt.read` and are audited.

| Method | Path                                 | Capability            | Purpose                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------ | ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/api/home`                          | session only          | Owner home: per-owned-domain posture (asset counts, pending review, open findings, unlabeled count, 7-day changes, attestation), plus capability-conditional sections (`pendingApprovals` under `action.approve`, `draftTaxonomy` under `governance.taxonomy.approve`, `scopes` coverage under `scan.job.read`).                                                                                        |
| GET    | `/api/landscape`                     | `asset.metadata.read` | "My Data Landscape" grouped cells: domain × information type × scope × provenance × label state × risk severity.                                                                                                                                                                                                                                                                                        |
| GET    | `/api/assets`                        | `asset.metadata.read` | Asset list (max 200). Filters: `?q=` (FTS; input is sanitized and quoted so operators like AND/OR/NEAR are literal terms), `?informationTypeId=`, `?labelState=`, `?riskSeverity=`.                                                                                                                                                                                                                     |
| GET    | `/api/assets/:id`                    | `asset.metadata.read` | Asset detail: location + version history, current label observation, permissions (with completeness caveat), risk components, assertion provenance chain, findings, clusters. `excerpts` is populated only under `asset.evidence.excerpt.read` (audited as `asset_excerpts_read`); `sourceUrl` is a deep link that relies on the user's own source permission. `404` for missing or non-visible assets. |
| GET    | `/api/assets/:id/connections`        | `asset.metadata.read` | Bounded graph neighborhood (depth 2, max 30 nodes) with per-edge reasons. Assets in domains the caller cannot see are redacted (`label: "(asset in another domain)"`) and never expanded.                                                                                                                                                                                                               |
| GET    | `/api/assets/:id/semantic-neighbors` | `asset.metadata.read` | Embedding-cosine neighbors (LSH retrieval, min cosine 0.55, top 8) filtered by the same visibility rule — semantic discovery never leaks a foreign-domain document.                                                                                                                                                                                                                                     |
| GET    | `/api/findings`                      | `finding.read`        | Findings (max 200), severity-ordered. `?status=` defaults to open-ish states (`open`/`acknowledged`/`reopened`); `?status=all` includes resolved. Domain-filtered for scoped callers.                                                                                                                                                                                                                   |
| GET    | `/api/ops/overview`                  | `operations.read`     | Operations: work-queue status counts, dead letters (50), tenant-filtered recent logs (100), scanner fleet health.                                                                                                                                                                                                                                                                                       |
| GET    | `/api/ops/metrics`                   | `operations.read`     | Telemetry snapshot: counters, latency histograms, and DB-derived gauges (queue pending/leased, dead letters, oldest pending age, delta-cursor lag, content-state distribution).                                                                                                                                                                                                                         |
| GET    | `/api/audit`                         | `audit.read`          | Hash-chained audit trail (200 most recent), optional `?category=` filter.                                                                                                                                                                                                                                                                                                                               |

### Admin (`admin/routes.ts`)

Setup progress, runtime + tenant settings, and a connectivity probe. Secrets are never returned — only booleans derived from configuration references.

| Method | Path                        | Capability             | Purpose                                                                                                                                                                                                                                                               |
| ------ | --------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/api/admin/setup-state`    | `tenant.profile.read`  | Onboarding checklist derived from observable state.                                                                                                                                                                                                                   |
| GET    | `/api/admin/settings`       | `tenant.profile.read`  | Runtime config (read-only, secrets as booleans) + tenant settings + evidence-level note.                                                                                                                                                                              |
| PATCH  | `/api/admin/settings`       | `tenant.profile.write` | Update tenant settings. Body (optional): `dataHandlingProfile` ∈ `minimized_evidence`\|`metadata_only`\|`enhanced_evidence`, `name`. Changing the profile bumps the tenant config version, invalidating evidence-bearing stage caches.                                |
| POST   | `/api/admin/connector/test` | `connector.test`       | Probe connector connectivity without a scan. Mock is always healthy; live acquires an app-only Graph token and does one cheap read (validated live against CDX). Missing credentials surface as `ok:false` with a safe reason — never a 500, never a secret. Audited. |

### Notifications (`notifications/routes.ts`)

Graph change-notification lifecycle: built end-to-end and validated via the dev simulator, which drove a real read-only delta reconcile against the live CDX tenant. **Live** Graph subscription delivery additionally requires `FMD_FEATURE_CHANGE_NOTIFICATIONS=true` and a public HTTPS receiver URL (`FMD_WEBHOOK_PUBLIC_URL`); `/api/ready` reports the mode honestly. A notification is treated as a *hint* that triggers delta reconciliation — notification content is never trusted.

| Method | Path                                     | Capability                            | Purpose                                                                                                                                                          |
| ------ | ---------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST   | `/api/webhooks/graph/notifications`      | UNAUTHENTICATED (webhook)             | Graph change-notification receiver. Returns `202` fast regardless of per-item outcome.                                                                           |
| POST   | `/api/webhooks/graph/lifecycle`          | UNAUTHENTICATED (webhook)             | Graph subscription lifecycle-event receiver (`202`).                                                                                                             |
| POST   | `/api/admin/notifications/subscribe`     | `connector.configure`                 | Create a change subscription for a scope. Body: `{ scopeId }`.                                                                                                   |
| GET    | `/api/admin/notifications/subscriptions` | `operations.read`                     | List subscriptions with expiry state.                                                                                                                            |
| GET    | `/api/admin/notifications/recent`        | `operations.read`                     | Recent notifications and their outcomes (reconciled / deduplicated / rejected).                                                                                  |
| POST   | `/api/admin/notifications/renew`         | `connector.configure`                 | Renew subscriptions nearing expiry; returns `{ renewed }`.                                                                                                       |
| POST   | `/api/dev/notifications/simulate`        | dev-mode only + `connector.configure` | Development injector (`404` outside dev): ensures a mock subscription for `{ scopeId }` and drives the real receiver + reconcile path without a public endpoint. |

Webhook trust model (both unauthenticated endpoints):

1. **Validation handshake** — a request with a `?validationToken=` query parameter is echoed back as `text/plain 200` (Graph's subscription-creation check).
2. **clientState HMAC** — each subscription's `clientState` is derived as `HMAC-SHA256(sessionSecret, "fmd-sub:<subscriptionId>")` (`deriveClientState`) and compared in constant time (`constantTimeEquals`). On the notifications receiver, `ingestNotification` rejects any item whose `clientState` does not verify **before any side effect**, and deliberately does not persist rejected rows (an unauthenticated caller who learns a subscription id must not be able to amplify writes); a metric records the rejection. On the lifecycle receiver, the same constant-time check runs before `handleLifecycleEvent` — an absent or wrong `clientState` is skipped, so an unauthenticated caller cannot force a reconcile or tamper with a subscription.
3. Verified notifications are deduplicated within a time window and enqueue a delta reconcile; the payload itself is never treated as content.

### Teams (`teams/routes.ts`)

Teams connector surface — **built and mock-validated**; the live Graph adapter is feature-gated behind `FMD_FEATURE_TEAMS_CONNECTOR` (requires the protected `ChannelMessage.Read.All` permission) and has not been run live. Channel file attachments canonicalize to their backing SharePoint driveItem, so no duplicate asset identities are created. Read views require tenant-scoped `operations.read` because Teams messages are not yet mapped to business-domain ownership — deny-by-default until they are.

| Method | Path                               | Capability            | Purpose                                                                                            |
| ------ | ---------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------- |
| POST   | `/api/teams/sync`                  | `connector.configure` | Sync teams → channels → messages (and canonicalize file attachments). Returns `{ summary, mode }`. |
| GET    | `/api/teams`                       | `operations.read`     | List synced teams/channels.                                                                        |
| GET    | `/api/teams/channels/:id/messages` | `operations.read`     | Messages for a channel (identifier-redacted previews).                                             |

### Exchange (`exchange/routes.ts`)

Exchange connector surface — **built and mock-validated**; live is feature-gated behind `FMD_FEATURE_EXCHANGE_CONNECTOR` (`Mail.Read` scoped to selected mailboxes via `FMD_EXCHANGE_MAILBOXES`) and has not been run live. An attachment is a *copy*: a content match to a SharePoint asset is recorded as **similarity, not identity**. Read views require tenant-scoped `operations.read` for the same deny-by-default reason as Teams.

| Method | Path                                 | Capability            | Purpose                                                                            |
| ------ | ------------------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
| POST   | `/api/exchange/sync`                 | `connector.configure` | Sync selected mailboxes via per-folder message delta. Returns `{ summary, mode }`. |
| GET    | `/api/exchange`                      | `operations.read`     | List synced mailboxes/folders.                                                     |
| GET    | `/api/exchange/folders/:id/messages` | `operations.read`     | Messages for a folder.                                                             |

### Control plane (`controlplane/routes.ts`)

Signed licensing entitlement (verified locally, offline grace window — no network calls from these endpoints) and strictly allowlisted fleet-health telemetry with a transparency preview and customer opt-out. This module also defines `PRODUCT_VERSION = "26.7.15.0"`.

| Method | Path                                  | Capability             | Purpose                                                                                                                                                                              |
| ------ | ------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| GET    | `/api/controlplane/entitlement`       | `tenant.profile.read`  | Entitlement state: `{ state, reason, plan, expiresAt, usage }`. `state` is `active`/`grace`/etc., or `unlicensed` when no token/secret is configured.                                |
| GET    | `/api/controlplane/telemetry-preview` | `operations.read`      | The **exact** fleet-health payload that would be reported: licensing + health bands only — no content, names, paths, or raw asset counts. Includes `telemetryEnabled` / `wouldSend`. |
| POST   | `/api/controlplane/telemetry`         | `tenant.profile.write` | Opt telemetry in/out. Body: `{ enabled: boolean }`. Audited.                                                                                                                         |

## Related documents

* [Permissions manifest](permissions-manifest.md) — roles, capability grants, and Microsoft Graph permission requirements
* [Threat model](threat-model.md) — the security invariants these endpoints enforce
* [Architecture](architecture.md) — where the route modules sit in the system
* [CDX test runbook](cdx-test-runbook.md) — record of what has been validated against the live CDX tenant
* [ADR-0003](adr/ADR-0003-persistence-single-sqlite-behind-role-interfaces.md) — persistence model behind these APIs
* [ADR-0004](adr/ADR-0004-authentication-dev-mode-and-entra.md) — dev/Entra authentication design
