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

# Security review

# Security review package (Architecture Review Board)

This document is the security review package for a customer organization's Architecture Review Board evaluating a deployment of **Find My Data**, release **26.7.15.0** (2026-07-15). It consolidates the deployment model, trust boundaries, identity and access design, data protection, secrets handling, Microsoft 365 permission posture, ingress security, audit, AI governance, and supply-chain posture into one reviewable package, with every control traced to the file or test that implements it. It is a companion to the [threat model](threat-model.md) and the [Microsoft permissions manifest](permissions-manifest.md); the [platform overview](platform-overview.md) explains the product itself.

**Honesty invariant.** Find My Data documentation never overstates its state. Every capability in this document is labeled with one of three statuses, and a gated capability is never described as simply working:

| Status label                   | Meaning                                                                                                                                                                                  |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Live-validated**             | Built, and exercised against the live Contoso CDX Microsoft 365 tenant ([ADR-0013](adr/ADR-0013-live-graph-connector.md), [CDX runbook](cdx-test-runbook.md) §0)                         |
| **Mock-validated, live-gated** | Built and tested against deterministic fixtures; the live path exists but is behind explicit configuration/feature gates and reports `blocked` or `disabled` until prerequisites are met |
| **Not built**                  | Designed and documented, with a seam where applicable, but no working implementation                                                                                                     |

`GET /api/ready` (`packages/server/src/routes/health.ts`) reports the current mode of every integration — `mock`, `live`, `blocked`, or `disabled` — so a reviewer can verify the running system's claims directly.

***

## 1. System context and deployment model

### 1.1 Deployment shape

Find My Data is **single-tenant and customer-hosted**. The entire data plane — API, worker, database, evidence artifacts, audit trail — runs inside the customer's own environment. There is no multi-tenant SaaS data plane.

* One server process (`packages/server/src/main.ts`) hosts the HTTP API (Hono) and an in-process durable worker loop over a SQLite-backed work queue ([ADR-0002](adr/ADR-0002-modular-monolith-and-worker.md)). The worker can also run as a separate process (`bun run worker`, `packages/server/src/worker-main.ts`) against the same database.
* The web UI is a separate React SPA (`packages/web/`) that talks only to the API.
* Persistence is a single SQLite file (`FMD_DB_PATH`, default `./data/fmd.db`) behind role interfaces ([ADR-0003](adr/ADR-0003-persistence-single-sqlite-behind-role-interfaces.md)), plus a purpose-flagged artifact directory (`FMD_ARTIFACT_DIR`, default `./data/artifacts`).
* Runtime is **Bun 1.3.14**; there is no Node.js dependency.

### 1.2 Azure reference deployment (`infra/main.bicep`)

The repository ships a resource-group-scoped Bicep template as the reference deployment. What it provisions (all verified in `infra/main.bicep`):

| Resource                       | Security-relevant configuration                                                                                                                                                                                                                                                                                                                  |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| User-assigned managed identity | The Container App's identity for Key Vault access — no credentials in app configuration                                                                                                                                                                                                                                                          |
| Key Vault                      | RBAC authorization (no access policies), soft delete (30 days), purge protection. Secrets `fmd-session-secret` and `fmd-graph-client-secret` are created as placeholders and set out-of-band via `az keyvault secret set` — **never in the template**                                                                                            |
| Role assignments               | Managed identity gets *Key Vault Secrets User* (read at runtime); a named operator gets *Key Vault Secrets Officer* (to set values)                                                                                                                                                                                                              |
| Storage account + file share   | `minimumTlsVersion: TLS1_2`, HTTPS-only traffic, public blob access disabled; SMB share `fmd-data` mounts at `/data` for the SQLite DB and artifacts                                                                                                                                                                                             |
| Log Analytics workspace        | 30-day retention; Container Apps environment logs route to it                                                                                                                                                                                                                                                                                    |
| Container App                  | External ingress on port 8710 with `allowInsecure: false` (TLS terminated by Container Apps). Environment pins `FMD_ENV=production` and `FMD_AUTH_MODE=entra`, so a deployment from this template cannot silently run development authentication. Secrets arrive as Key-Vault-backed Container App secrets resolved through the managed identity |

Honest caveats the ARB should weigh:

* The template is **built but has not been deployed and validated live**; [STATUS.md](../STATUS.md) records `az bicep build` validation as pending a CLI environment.
* The template sets `FMD_AUTH_MODE=entra`, but the Entra OIDC sign-in flow is **not built** (see §3.2) — interactive sign-in against a deployment of this template returns 501 today. The template is a hardened target shape, not a turnkey production system.
* Key Vault `publicNetworkAccess` is `Enabled` in the template, with an inline comment directing production deployments to tighten to Private Endpoints. Scale beyond single-writer SQLite (managed PostgreSQL, separate worker app) is documented, not templated.

### 1.3 SaaS control plane: licensing and fleet health only

The vendor control plane ("Find My Data Control") never receives customer content. The customer-side implementation exists (`packages/server/src/controlplane/`):

* **Entitlement (licensing)**: a signed token verified *locally* (`entitlement.ts`) with an offline grace window — the product keeps running from the last valid entitlement if Control is unreachable. This build verifies with HMAC-SHA256 over a shared secret; production is documented as asymmetric verification against Control's public key. The token carries licensing metadata only (plan, limits, validity window).
* **Fleet-health telemetry**: a strictly allowlisted payload (§4.5) with a transparency preview and customer opt-out.
* **Not built:** the receiving Control endpoint itself does not exist. **Nothing is transmitted anywhere today**; the telemetry payload is assembled, validated, and previewable locally.

***

## 2. Trust boundaries and data flows

The authoritative boundary analysis, threat scenarios (T1–T16), and residual-risk table are in the [threat model](threat-model.md). Summary view (boundary numbers match the threat model's §1.2):

```mermaid theme={null}
flowchart LR
  subgraph cust["Customer trust boundary — single-tenant deployment"]
    browser["Browser SPA - untrusted client"]
    api["API + durable worker - Bun process"]
    db[("SQLite fmd.db")]
    art[("Artifact store - purpose-flagged reads")]
    browser -- "B1: signed HttpOnly session cookie + CSRF header" --> api
    api -- "B3: full trust, tenant-scoped queries" --> db
    api -- "B4: purpose-checked reads" --> art
  end
  m365["Microsoft 365 - Graph API"]
  aip["AI provider - mock default, live adapters gated"]
  control["Find My Data Control - vendor SaaS"]
  api -- "B5/B6: read identity - delta, metadata, bounded content" --> m365
  api -- "B9: remediation identity - gated Purview label write" --> m365
  m365 -- "webhooks: clientState HMAC + validation handshake" --> api
  api -- "B7: AI gateway - delimited untrusted content" --> aip
  api -. "B10: licensing + fleet-health bands only, opt-out - endpoint not built" .-> control
```

Key properties, each expanded in the sections below:

| Boundary                          | Trust posture                                                                                                                                                                                                        | Status                                                                  |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Browser ↔ API (B1)                | Untrusted client; identity only from the signed session cookie; client-supplied identity fields never trusted; CSRF header on all mutations                                                                          | Live in every mode                                                      |
| App ↔ SQLite / artifacts (B3/B4)  | Fully trusted by the application; every row tenant-keyed; artifact reads purpose-checked; **no application-layer encryption** (known limitation)                                                                     | Live in every mode                                                      |
| Scan pipeline ↔ connector (B5/B6) | Connector *content* is always treated as untrusted input: bounded fetch, magic-byte sniffing, size caps. Mock connector is the default/CI path; the live Graph connector is **live-validated read-only** against CDX | Mixed — see §6                                                          |
| App ↔ AI providers (B7)           | All model calls via a provider-neutral gateway; untrusted content delimited; missing config is an explicit `BlockedError`, never a fallback                                                                          | Mock live-exercised; Azure OpenAI/Anthropic adapters built, unexercised |
| Graph webhooks (ingress)          | Unauthenticated by necessity; trust flows entirely from a per-subscription `clientState` HMAC verified in constant time before any side effect                                                                       | Built; receiver validated via simulator + live CDX reconcile            |
| Entra ID (B8)                     | OIDC seam designed, dev-mode locked out of production                                                                                                                                                                | Sign-in flow **not built**                                              |
| Control plane (B10)               | Licensing + health bands only; strict schema; opt-out                                                                                                                                                                | Customer side built; vendor endpoint **not built**                      |

***

## 3. Identity and access

### 3.1 Application identities (Microsoft side)

The live path uses **separate Entra ID app registrations with independent credentials** so that the component that touches untrusted content at volume (the scanner) never holds write capability ([permissions manifest §1.1](permissions-manifest.md)):

| Identity                               | Purpose                                                                                                   | Config keys                                                                  | Status                                                                                                                                                                                                |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read/scan                              | Scope discovery, `driveItem` delta, metadata, permission and label observation, bounded content retrieval | `FMD_GRAPH_TENANT_ID`, `FMD_GRAPH_CLIENT_ID`, `FMD_GRAPH_CLIENT_SECRET_REF`  | Live-validated read-only (CDX app `FMD-CDX-Reader`)                                                                                                                                                   |
| Remediation                            | Purview label assignment/removal only                                                                     | `FMD_GRAPH_REMEDIATION_CLIENT_ID`, `FMD_GRAPH_REMEDIATION_CLIENT_SECRET_REF` | Built, mock-validated, live-gated (§6); write path refuses to run when this identity is unconfigured                                                                                                  |
| Notifications (planned third identity) | Graph change-notification subscriptions                                                                   | —                                                                            | Subscriptions are built and currently ride on the read identity (they need no extra Graph permission); splitting them onto a dedicated identity is the documented recommendation, not yet implemented |

This separation gives blast-radius containment (a scanner compromise cannot mutate tenant state), independent rotation/revocation, and consent screens that map 1:1 to product capabilities.

### 3.2 User authentication

* **Dev identity mode** (`FMD_AUTH_MODE=dev`, [ADR-0004](adr/ADR-0004-authentication-dev-mode-and-entra.md)) is the exercised path: a persona picker over seeded fixture principals, a persistent UI banner, and `x-fmd-auth-mode` on every response.
* **Production lockout is enforced, not advisory** (`packages/server/src/kernel/config.ts`): `FMD_ENV=production` with `FMD_AUTH_MODE=dev` is a hard startup failure (`ConfigError`, tested in `kernel/config.test.ts`), and production also refuses the default session secret at boot. Defense in depth: even if a dev-mode session cookie exists, production rejects it at request time (`devSessionInProd` in `packages/server/src/kernel/http.ts`), and dev persona endpoints 404 outside development.
* **Entra OIDC** is the production seam, designed in ADR-0004 but **not built**: the sign-in endpoint returns an explicit 501 (`packages/server/src/identity/auth-routes.ts`), and no OIDC token-validation code exists yet. The schema is ready for it — external identities map to internal principals by a unique `(issuer, subject)` index (migration `0001`), never by email or display name. `/api/ready` reports `blocked` or `disabled`, never a false `live`.

### 3.3 Session security

Implemented in `packages/server/src/identity/sessions.ts` and `packages/server/src/kernel/http.ts`:

| Control              | Implementation                                                                                                                                  |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Cookie integrity     | Cookie `fmd_session` carries `sessionId.HMAC-SHA256(secret, sessionId)`; the MAC is verified with a constant-time compare before any DB lookup  |
| Cookie flags         | `HttpOnly`, `SameSite=Lax`, `Secure` when `FMD_ENV=production`, `Path=/`                                                                        |
| CSRF                 | Every `POST`/`PUT`/`PATCH`/`DELETE` must present the per-session CSRF token in the `x-fmd-csrf` header; mismatch is a 403 before the route runs |
| Lifetime             | Sessions expire after 8 hours and support server-side revocation (`revoked_at`)                                                                 |
| Server-side identity | The auth context is resolved from the session row only; no client-supplied identity field is ever trusted                                       |

Residual (recorded in the threat model, T4): the HMAC key is a single env-provided secret with no rotation story in the slice (Key Vault in the Azure design is the rotation seam), and there is no step-up authentication for sensitive actions.

### 3.4 Authorization: deny-by-default capability model

Enforcement uses **capabilities, not roles** (`packages/shared/src/capabilities.ts`, `packages/server/src/kernel/authz.ts`). A request is allowed only when the session principal holds the required capability at a scope covering the resource; UI hiding is never enforcement.

* **41 capability strings** in a closed namespace (e.g. `asset.evidence.excerpt.read`, `action.approve`, `audit.read`).
* **5 visible roles** (`platform_admin`, `governance_admin`, `domain_owner`, `remediation_approver`, `auditor`) map to `(capability, scope)` grants where scope is `tenant` or `domain`.
* **Domain scoping is resolved at evaluation time** against active `ownership_assignments` (validity-windowed), not cached in the session. A domain-scoped grant with no domain in the check is permitted only for "list my things" calls, and callers must then filter by `domainScopeFilter()` — a contract that was adversarially reviewed and hardened with regression tests (`packages/server/src/testing/authz-fixes.test.ts`).
* **Deliberate exclusions**: `platform_admin` holds no `asset.evidence.*`, `review.*`, or `analyst.*` capabilities — operating the platform does not grant access to business content. `remediation_approver` approves and cancels but cannot draft or submit; `domain_owner` drafts and submits but cannot approve (separation of duties, enforced per action with an audited `approval_self_dealing_denied` event, `packages/server/src/actions/service.ts`, tested in `actions.test.ts`).
* **Existence does not leak**: cross-domain object probes return 404, not 403 (tested in `packages/server/src/review/review.test.ts`).
* Every authorization denial appends an `authorization_denied` audit event (`http.ts` `onError`).

***

## 4. Data protection

### 4.1 Data-handling profiles

Each tenant carries a `data_handling_profile` (migration `0001`): `minimized_evidence` (default, implemented and exercised), with `metadata_only` and `enhanced_evidence` as schema-ready values that are **not implemented beyond configuration validation**. The minimized-evidence profile is specified in [ADR-0011](adr/ADR-0011-retention-and-evidence-profile.md); what is and is not persisted is enumerated in [threat model §4](threat-model.md).

### 4.2 Evidence access levels

Evidence access is tiered by capability, so seeing that an asset exists never implies seeing its content:

1. `asset.metadata.read` — names, locations, versions
2. `asset.evidence.summary.read` — detector/keyphrase summaries
3. `asset.evidence.excerpt.read` — bounded redacted excerpts (callers without it receive `excerpts: null`)
4. `asset.source.open` — deep link to the source system

Excerpt reads are additionally domain-scoped and audited (`evidence_access` audit category).

### 4.3 Bounded excerpts and preview redaction

* Owner-review excerpts are bounded to **≤ 240 characters each, ≤ 3 per asset version** (`EXCERPT_MAX_CHARS`, `EXCERPT_MAX_COUNT` in `packages/server/src/scan/pipeline.ts`) and pass through digit masking (SSN-like patterns, currency amounts, dates) before storage. Honest caveat from the threat model: the redaction patterns are fixture-tuned (US formats) — a demonstration of the mechanism, not a production-grade PII redactor.
* Teams/Exchange message previews are length-bounded and identifier-redacted before persistence (`packages/server/src/intelligence/redact.ts`: SSN, card-like, and long digit runs masked); detector *signals* are kept, raw identifier values are not. This redaction was one of the five second-review findings and carries a regression test.

### 4.4 Log redaction (default-deny)

The logger (`packages/server/src/kernel/log.ts`) enforces redaction **structurally**: only allowlisted field keys (`SAFE_KEYS`) are emitted; string values must match a conservative opaque-token pattern (`SAFE_VALUE`, `^[A-Za-z0-9_.:/-]{1,120}$`) or are replaced with `[redacted]`; unknown keys are dropped and counted; sizes are logged as bands (`sizeBand`). Names, paths, URLs, excerpts, prompts, tokens, hashes, and embeddings never appear in default logs — asserted by an end-to-end log-scan test (`kernel/log.test.ts` and the acceptance suite). Unhandled HTTP errors log only the error class name.

### 4.5 What never leaves the boundary

The only data ever destined for the vendor is the fleet-health payload (`packages/server/src/controlplane/telemetry.ts`), and it is guarded three ways:

1. **Strict allowlist schema**: `FleetTelemetrySchema` is a Zod `.strict()` object — any field not on the allowlist fails validation, so an over-sharing field can never be added by accident. The payload is validated before it is returned (`buildFleetTelemetry` fails closed).
2. **Denylist tripwire**: `SENSITIVE_FIELD_DENYLIST` (substrings like `name`, `path`, `excerpt`, `content`, `domain`, `asset`, `email`, `tenantid`, `secret`, `token`) backs a test (`controlplane.test.ts`) so a future edit that adds a sensitive field name fails CI.
3. **Coarse values only**: the deployment id is a non-reversible hash of the internal tenant id; queue depth, dead letters, and scale are reported as **bands**, never exact figures; the only raw counts permitted are scanner counts (operational, not content).

And to restate §1.3: since no Control endpoint exists, nothing is transmitted at all in this release; the payload is inspectable via the transparency preview and is opt-out.

### 4.6 Retention

Bounds are implemented (excerpt caps, bounded extracted text, purpose flags); retention *durations* are undecided and **no scheduled deletion job exists** — `retention_class` is recorded intent, not enforced behavior ([threat model §4.3](threat-model.md)). ADR-0011 also documents an honest deviation: bounded extracted text (≤ 20,000 chars) **is** retained as a purpose-flagged artifact to enable source-free re-scoring, with envelope encryption and per-class TTLs as recorded follow-ups.

Every artifact read must declare a purpose, and absence of the flag means denial (`ObjectStore.get`, `packages/server/src/kernel/objectstore.ts`). Extracted-text artifacts carry `classification_inference` and `owner_review` flags only; no artifact in this build carries `training` or `evaluation` flags.

***

## 5. Secrets management

| Mechanism                                                                                                                                                                                                                                                                                                    | Where                                                |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| **Reference indirection**: configuration stores env-var *references* (`*_REF`), never secret values — e.g. `FMD_GRAPH_CLIENT_SECRET_REF` names the env var that holds the secret                                                                                                                             | `packages/server/src/kernel/config.ts`               |
| **No secrets in the database**: credentials never persist in SQLite; the config layer validates presence of references only                                                                                                                                                                                  | `config.ts`, [threat model §2](threat-model.md)      |
| **No secrets in the IaC template**: Key Vault secrets are created as placeholders and set out-of-band; the Container App resolves them at runtime via managed identity (`secretRef`), and the template wires `FMD_GRAPH_CLIENT_SECRET_REF=GRAPH_CLIENT_SECRET` to the Key-Vault-backed `GRAPH_CLIENT_SECRET` | `infra/main.bicep`                                   |
| **No secrets in logs**: allowlist logger (§4.4); the settings UI shows secrets as booleans only                                                                                                                                                                                                              | `kernel/log.ts`, `packages/server/src/admin/`        |
| **Boot-time refusal of weak defaults**: production refuses the default session secret                                                                                                                                                                                                                        | `config.ts` (tested)                                 |
| **Never stored raw**: the webhook `clientState` secret is persisted only as a SHA-256 hash (§7)                                                                                                                                                                                                              | `packages/server/src/notifications/subscriptions.ts` |

Residual (threat model T14): once live credentials exist, a leaked env var is a full credential in the local slice; managed identity + Key Vault in the Azure design is the mitigation, and a rotation/revocation runbook is a documented prerequisite for live tenant connection.

***

## 6. Microsoft 365 permissions

The full manifest — every permission, why it is needed, its least-privilege alternative, and its status — is the [Microsoft permissions manifest](permissions-manifest.md). ARB-relevant posture:

* **Least privilege by default**: the recommended read/scan grant is `Sites.Selected`, which grants nothing until both admin consent *and* an explicit per-site grant exist. `Files.Read.All` is the documented fallback only where selected models cannot cover a target. The live CDX pilot ran with `Sites.Read.All` + `Files.Read.All` for speed and the manifest says so explicitly; `Sites.Selected` remains the production recommendation.
* **Protected/metered APIs are treated as gates, not details**: Purview label write (`assignSensitivityLabel`) is a protected, metered API requiring a Microsoft enablement process; the product reports `purviewLabelWrite: blocked` and transitions actions to a blocked state with a reason rather than simulating success. Teams message acquisition (`getAllMessages`) is likewise protected/licensed and separately gated.
* **Exchange scoping**: the Exchange connector reads only explicitly listed mailboxes (`FMD_EXCHANGE_MAILBOXES`), with Exchange Online Application Access Policy / RBAC-for-Applications scoping as the documented deployment requirement — never indiscriminate tenant-wide capture.
* **Honest observation semantics**: permission observations carry a `completeness` state (`complete | partial | not_observable`); "no broad share observed" is never presented as "none exists". Label state is reported `unknown` when the label-read permission is absent — the CDX run did exactly this rather than guessing.

***

## 7. Webhook / ingress security

The Graph change-notification receivers (`/api/webhooks/graph/notifications`, `/api/webhooks/graph/lifecycle` in `packages/server/src/notifications/routes.ts`) are necessarily **unauthenticated** — Microsoft Graph posts to them. Trust is established as follows (`packages/server/src/notifications/subscriptions.ts`):

* **Per-subscription secret**: `clientState` is derived as `HMAC-SHA256(sessionSecret, "fmd-sub:" + subscriptionId)` and registered with Graph at subscription creation. It is **never persisted raw** — only its SHA-256 hash is stored (for audit display), and the receiver recomputes the expected value on demand.
* **Constant-time verification before any side effect**: the receiver compares with `timingSafeEqual`; nothing in the notification body is trusted until the comparison passes. The lifecycle receiver applies the same verification — an early bypass when the field was absent was found in adversarial review and fixed with a regression test ([ADR-0014](adr/ADR-0014-roadmap-features.md) §1).
* **No write on mismatch**: a failed or unknown-subscription verification records a metric only — no database row is written, so an attacker who learns an external subscription id cannot amplify writes into the tenant. (This was itself a fixed review finding.)
* **Validation handshake**: subscription creation echoes Graph's `validationToken` as `text/plain`, per the Graph contract.
* **Notifications are hints, never content**: a verified notification only triggers reconciliation through the existing authenticated delta feed; deduplication bounds repeat hints.

Status honesty: the receiver, lifecycle handling, renewal sweep, and reconcile path are built and tested (`notifications.test.ts`); live Graph subscriptions additionally require `FMD_FEATURE_CHANGE_NOTIFICATIONS=true` and a public HTTPS `FMD_WEBHOOK_PUBLIC_URL`, and are an explicit `BlockedError` otherwise. A dev simulator drives the same receiver path; on CDX, a simulated notification triggered a real read-only delta reconcile.

***

## 8. Audit and tamper evidence

Implemented in `packages/server/src/kernel/audit.ts`:

* **Append-only, per-tenant hash chain**: each `audit_events` row links to its predecessor via `prev_hash` and a SHA-256 hash over the tenant id, sequence number, actor, category, action, target, outcome, safe details, and timestamp; the append runs in an immediate transaction so sequence numbers cannot interleave.
* **Category coverage**: ten categories (`auth`, `config`, `governance`, `scan`, `evidence_access`, `review`, `analyst`, `action`, `model`, `operations`) span the privileged surface — authorization denials, evidence/excerpt access, review decisions, Analyst queries, the full action lifecycle including self-approval denials, model releases, and configuration changes. Category coverage across the full product journey is asserted by test (acceptance suite; see [STATUS.md](../STATUS.md)).
* **Content-safe by contract**: `details_safe_json` holds only structured references (ids, versions, outcomes) — never excerpts, paths, prompts, or names.
* **Verification**: `verifyAuditChain` recomputes the chain and returns the sequence number of the first broken link (`kernel/audit.test.ts`).

Honest limitation (threat model T15): the chain **detects** tampering; it does not prevent it. An attacker with direct database write access can rewrite the chain from genesis. External anchoring of chain heads (write-once store or customer SIEM) is the documented follow-up, not built.

***

## 9. AI governance

* **Provider-neutral gateway**: every model call goes through `AiGateway` (`packages/server/src/aigateway/gateway.ts`). The default — and only exercised — provider is a **deterministic mock**; Azure OpenAI and Anthropic adapters are built and config-gated. Selecting a live provider without complete configuration is an explicit `BlockedError`, never a silent fallback to a differently governed provider ([ADR-0005](adr/ADR-0005-ai-provider-gateway.md)).
* **Provenance for every generation**: each call records a `generation_runs` row — task, provider, model, template version, and an input **hash** (never prompt bodies) — with a failure row recorded on error.
* **Versioned templates with injection guards**: prompt templates are versioned (`packages/server/src/aigateway/templates.ts`), and all untrusted input (document content, user questions) is wrapped in labeled `<untrusted-data>` delimiters with an explicit not-an-instruction rule (`INJECTION_GUARD`, `renderUntrusted`).
* **The real security boundary is outside the model**: any plan the model produces is treated as untrusted output — Zod-validated against an allowlisted semantic schema, with tenant/domain predicates injected server-side that the plan cannot set or widen (`packages/server/src/analyst/executor.ts`). Injection resistance is tested with hostile questions and hostile fixture documents (`analyst/analyst.test.ts`); the threat model is explicit that this is proven only against the mock provider and must be re-run before enabling live providers.
* **Human approval with separation of duties**: AI taxonomy output is a draft requiring human approval (`governance.taxonomy.approve`); model/ensemble releases require approval by a principal **different from the proposer**, enforced in code (`packages/server/src/models/pipeline.ts`), not just by capability split, and tested (`models/pipeline.test.ts`). Nothing material auto-applies.

***

## 10. Supply chain and SDLC

**Deliberately minimal runtime dependency surface** (verified against the workspace `package.json` files; `bun.lock` is committed):

| Package       | Runtime dependencies                                                               |
| ------------- | ---------------------------------------------------------------------------------- |
| `@fmd/server` | `hono` ^4.6.0, `zod` ^3.23.8 (+ `@fmd/shared` workspace)                           |
| `@fmd/shared` | `zod` ^3.23.8                                                                      |
| `@fmd/web`    | `react` ^18.3.1, `react-dom` ^18.3.1, `react-router-dom` ^6.26.0 (+ `@fmd/shared`) |

Crypto, SQLite, and the test runner are Bun built-ins. Dev-only tooling: `typescript`, `oxlint`, `vite`/`@vitejs/plugin-react`, type packages.

**Verification gates** ([STATUS.md](../STATUS.md) Verification record):

* `bun test packages/shared packages/server` → **196 pass / 0 fail** across 30 test files, including the security/acceptance suite (IDOR/404, production-refuses-dev-auth at boot and request level, analyst injection, action idempotency/drift/self-approval, end-to-end log-redaction scan, audit chain verification).
* `bun run typecheck` (all three packages), `bun run lint`, and `bun run build` are clean.
* **Two multi-agent adversarial review passes**: the first (5 lenses) confirmed 26 findings on the base build, all fixed with regression tests ([review findings](review-findings.md)); the second (3 lenses) on the roadmap features confirmed 5 findings — lifecycle-webhook clientState bypass, unauthenticated write-amplification, Teams external-attachment idempotency, `/api/ready` blocked-vs-live honesty, message-preview identifier redaction — all fixed with regression tests ([ADR-0014](adr/ADR-0014-roadmap-features.md)).
* All fixture data is synthetic (fictional Meridian Grove Holdings); no real tenant data or credentials in the repository.

Honest gaps (threat model T13): dependency versions use `^` ranges (pinned only by the lockfile); there is **no vulnerability scanning, SBOM generation, artifact signing, or CI provenance** yet; Bun itself is trusted implicitly. These are recorded follow-ups, not claimed controls.

***

## 11. Residual risks and known limitations

Stated plainly, from [STATUS.md](../STATUS.md) and the [threat model](threat-model.md):

1. **Single-writer SQLite.** Fine at slice scale; the migration triggers and the PostgreSQL/Blob/Service Bus path are documented in [capacity-model.md](capacity-model.md) and ADR-0003. Authorization is application-layer over one shared file — there is no physical tenant separation and no row-level security.
2. **Entra OIDC sign-in is not built.** Production boot enforces `FMD_AUTH_MODE=entra`, but the code flow returns 501 and no OIDC token-validation implementation exists yet. A production deployment is not usable for interactive sign-in until this lands.
3. **Cross-tenant isolation is enforced by convention, not adversarially exercised.** Every table is tenant-keyed and repositories refuse unscoped queries, but only one tenant has ever been seeded (threat model T11).
4. **Audit is tamper-evident, not tamper-proof** — no external anchoring of chain heads yet (T15).
5. **Retention sweeps are not implemented**; `retention_class` is recorded intent (ADR-0011). Bounded extracted text is retained by design, documented as a deviation.
6. **Mock-tenant fixture state is in-memory** — a server restart resets fixture mutations while the DB keeps observations (dev-only quirk; a delta scan reconciles).
7. **Activity signals are not enabled**; risk coverage surfaces `not_enabled` honestly rather than guessing.
8. **Teams/Exchange messages are not yet mapped to business-domain ownership**, so their read views are admin/audit-scoped (deny-by-default) until that mapping exists.
9. **AI injection resistance is proven against the mock provider only**; live providers require re-running the injection suite before enablement (T5).
10. **No TLS inside the slice** — transport security is a deployment concern (Container Apps ingress terminates TLS in the reference deployment); no secret rotation, no step-up auth, no support-access model, no disaster-recovery targets defined.
11. **Live label write and live subscriptions remain gated** on Microsoft protected-API enablement and a public webhook URL respectively; both report `blocked` honestly until then.
12. Minor functional gaps: finding status transitions are read-only in the UI; action execution runs synchronously in-request via the queue-compatible executor; one Analyst aggregate (`lastModifiedAt` per group) returns null.

***

## 12. ARB checklist: control → implementation → evidence

| #  | Control                                                                                     | Implementation                                                                                                                       | Evidence pointer                                                                                                                                        |
| -- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1  | Production refuses development authentication at boot                                       | `buildConfig` hard-fails on `FMD_ENV=production` + `FMD_AUTH_MODE=dev`; default session secret also refused                          | `packages/server/src/kernel/config.ts` · `packages/server/src/kernel/config.test.ts`                                                                    |
| 2  | Dev sessions rejected in production at request time                                         | `devSessionInProd` check in base middleware                                                                                          | `packages/server/src/kernel/http.ts` · acceptance suite (`packages/server/src/testing/acceptance.test.ts`)                                              |
| 3  | Signed, HttpOnly, SameSite session cookie; constant-time MAC check                          | `sessionCookieValue` / `parseSessionCookie`; `issueSessionCookie` flags                                                              | `packages/server/src/identity/sessions.ts` · `packages/server/src/kernel/http.ts` · `packages/server/src/identity/auth.test.ts`                         |
| 4  | CSRF token required on all mutating requests                                                | `x-fmd-csrf` checked against the session row before routing                                                                          | `packages/server/src/kernel/http.ts`                                                                                                                    |
| 5  | Deny-by-default capability authorization with domain scoping                                | 41 capabilities, 5 roles, `(capability, scope)` grants; ownership resolved at evaluation time                                        | `packages/shared/src/capabilities.ts` · `packages/server/src/kernel/authz.ts` · `kernel/authz.test.ts`, `testing/authz-fixes.test.ts`                   |
| 6  | Platform admin cannot read business content                                                 | Deliberate capability exclusions for `platform_admin`                                                                                | `packages/shared/src/capabilities.ts` ("Deliberate exclusions")                                                                                         |
| 7  | Cross-domain probes do not leak existence (404, not 403)                                    | Object loads keyed `(tenant_id, id)` then domain-checked                                                                             | `packages/server/src/review/service.ts` · `review/review.test.ts`                                                                                       |
| 8  | Separation of duties on remediation                                                         | Requester can never approve their own action; violation audited                                                                      | `packages/server/src/actions/service.ts` · `actions/actions.test.ts`                                                                                    |
| 9  | Read vs. remediation Graph identities separated                                             | Distinct app registrations and config slots; write path refuses when remediation identity unconfigured                               | [permissions-manifest.md](permissions-manifest.md) §1.1 · `packages/server/src/kernel/config.ts` · `connectors/graph/gating.test.ts`                    |
| 10 | Least-privilege M365 permission manifest with protected-API gating                          | `Sites.Selected` recommendation; label write gated on protected-API enablement + feature flag                                        | [permissions-manifest.md](permissions-manifest.md) §2 · `/api/ready` (`routes/health.ts`, `routes/health.test.ts`)                                      |
| 11 | Webhook ingress: clientState HMAC, constant-time, sha256-only at rest, no write on mismatch | `deriveClientState`, `constantTimeEquals`, mismatch → metric only; validation-token handshake                                        | `packages/server/src/notifications/subscriptions.ts` · `notifications/routes.ts` · `notifications/notifications.test.ts`                                |
| 12 | Bounded, redacted evidence excerpts behind a dedicated capability                           | ≤ 240 chars × ≤ 3, digit masking; `asset.evidence.excerpt.read` domain-scoped                                                        | `packages/server/src/scan/pipeline.ts` · `review/service.ts` · `review/review.test.ts`                                                                  |
| 13 | Purpose-flagged artifact reads (absence = denied)                                           | `ObjectStore.get` requires a declared purpose                                                                                        | `packages/server/src/kernel/objectstore.ts`                                                                                                             |
| 14 | Default-deny log redaction                                                                  | `SAFE_KEYS` allowlist + opaque-value pattern + dropped-key counting + size bands                                                     | `packages/server/src/kernel/log.ts` · `kernel/log.test.ts` · end-to-end scan in acceptance suite                                                        |
| 15 | Message-preview identifier redaction                                                        | SSN/card/long-digit masking before persistence                                                                                       | `packages/server/src/intelligence/redact.ts` · `intelligence/redact.test.ts`                                                                            |
| 16 | Telemetry cannot leak content                                                               | `.strict()` allowlist schema, banded values, hashed deployment id, denylist tripwire test, opt-out                                   | `packages/server/src/controlplane/telemetry.ts` · `controlplane/controlplane.test.ts`                                                                   |
| 17 | Secrets never in code, config values, logs, or IaC                                          | `*_REF` env indirection; Key Vault + managed identity; placeholder secrets in template                                               | `packages/server/src/kernel/config.ts` · `infra/main.bicep`                                                                                             |
| 18 | Tamper-evident audit across privileged operations                                           | Per-tenant SHA-256 hash chain, append-only, 10 categories, `verifyAuditChain`                                                        | `packages/server/src/kernel/audit.ts` · `kernel/audit.test.ts` · acceptance suite                                                                       |
| 19 | AI outputs are untrusted; scope enforced outside the model                                  | Delimited untrusted input; Zod-validated plans; server-injected tenant/domain predicates                                             | `packages/server/src/aigateway/templates.ts` · `analyst/executor.ts` · `analyst/analyst.test.ts`                                                        |
| 20 | AI provenance and no silent provider fallback                                               | `generation_runs` (input hash only); `BlockedError` on missing provider config                                                       | `packages/server/src/aigateway/gateway.ts`                                                                                                              |
| 21 | Human approval for taxonomy and model releases (SoD)                                        | Draft → human approval; release approver ≠ proposer enforced in code                                                                 | `packages/server/src/models/pipeline.ts` · `models/pipeline.test.ts` · `governance/governance.test.ts`                                                  |
| 22 | Parser-bomb / malformed-content limits                                                      | 5 MB bounded content fetch (`CONTENT_MAX_BYTES`), 25 MB extractor input cap, 20k-char output cap, magic-byte sniffing before parsing | `packages/server/src/scan/pipeline.ts` · `packages/server/src/intelligence/extract.ts` · `intelligence/extract.test.ts`                                 |
| 23 | Idempotent, drift-checked, source-verified remediation                                      | Idempotency keys, drift → re-preview, verification re-reads source                                                                   | `packages/server/src/actions/service.ts` · `actions/actions.test.ts` · [ADR-0010](adr/ADR-0010-remediation-state-machine.md)                            |
| 24 | Honest capability reporting                                                                 | `/api/ready` reports `mock`/`live`/`blocked`/`disabled`; blocked-vs-live fixed under adversarial review                              | `packages/server/src/routes/health.ts` · `routes/health.test.ts`                                                                                        |
| 25 | Adversarially reviewed with fixes regression-tested                                         | Two multi-agent passes; 26 + 5 confirmed findings fixed                                                                              | [review-findings.md](review-findings.md) · [ADR-0014](adr/ADR-0014-roadmap-features.md) · `testing/authz-fixes.test.ts`, `testing/review-fixes.test.ts` |

***

*Related reading: [threat model](threat-model.md) · [permissions manifest](permissions-manifest.md) · [platform overview](platform-overview.md) · [CDX test runbook](cdx-test-runbook.md) · [capacity model](capacity-model.md) · [architecture](architecture.md) · [STATUS.md](../STATUS.md) · ADRs [0001](adr/ADR-0001-runtime-language-repo-layout.md)–[0014](adr/ADR-0014-roadmap-features.md).*
