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

# Decisions

# Decision register (ADR index)

This register is for engineers and reviewers who need to know why the Find My Data platform (release 26.7.15.0) is built the way it is: it indexes every architecture decision record in [docs/adr/](adr/), states each decision in one line, and summarizes the decision plus its key consequence or tradeoff so you can decide whether the full ADR is worth opening. Status wording follows the ADRs themselves, which distinguish what is built and validated live against the CDX tenant, what is built and mock-validated with the live path feature-gated, and what is documented but not built.

## Decision language and when an ADR is required

The [handoff package](../find_my_data_handoff/README_FIND_MY_DATA_HANDOFF.md) that seeded this build uses three levels of commitment:

| Level          | Meaning                                                                                                                |
| -------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Invariant**  | A product or security property that should not change without explicitly documenting why.                              |
| **Baseline**   | The current recommended direction. It may be replaced with a demonstrably better option — recorded in an ADR.          |
| **Suggestion** | A starting point (technologies, UI execution, algorithms, branding). Broad freedom, no ADR needed for routine choices. |

An ADR is required when a choice is **consequential or reversible only with cost**: replacing a handoff Baseline, or exercising one of the handoff's "deliberately delegated decisions" (languages and frameworks, service boundaries, store technologies, graph representation, model and similarity choices, deployment approach). Deviations from handoff guidance are recorded explicitly — see the retained-extracted-text deviation in [ADR-0011](adr/ADR-0011-retention-and-evidence-profile.md).

All twenty-two ADRs are **Accepted**. ADR-0001–0012 come from the initial vertical-slice build (2026-07-14); ADR-0013 (live CDX), ADR-0014 (roadmap), ADR-0015 (update-001), ADR-0016 (vendor control plane), ADR-0017 (Account portal), ADR-0018 (entitled downloads + supply chain), ADR-0019 (support portal + diagnostic bundles), ADR-0020 (production IdP / Clerk), ADR-0021 (Stripe billing), and ADR-0022 (demo & dev environments) all land on 2026-07-15.

## Summary table

| #                                                                        | Title                                                                                               | Status   | Date       | Decision in one line                                                                                                                                                                                                                                                                                                                                         |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [0001](adr/ADR-0001-runtime-language-repo-layout.md)                     | Runtime, language, and repository layout                                                            | Accepted | 2026-07-14 | TypeScript everywhere on Bun 1.3.x (Hono, Zod, React + Vite) in a Bun-workspaces monorepo of `shared` / `server` / `web`.                                                                                                                                                                                                                                    |
| [0002](adr/ADR-0002-modular-monolith-and-worker.md)                      | Modular monolith + in-process durable worker                                                        | Accepted | 2026-07-14 | One deployable process hosts the HTTP API and a durable worker loop over a SQLite-backed queue, with strict internal module boundaries.                                                                                                                                                                                                                      |
| [0003](adr/ADR-0003-persistence-single-sqlite-behind-role-interfaces.md) | One SQLite database behind role-specific store interfaces                                           | Accepted | 2026-07-14 | A single SQLite file (WAL) is accessed only through logical store roles mirroring the polyglot target (per-module repos, `ObjectStore`, the FTS5 search projection, the graph projection, `WorkQueue`, the fingerprint index).                                                                                                                               |
| [0004](adr/ADR-0004-authentication-dev-mode-and-entra.md)                | Authentication — explicit dev identity mode, Entra OIDC seam                                        | Accepted | 2026-07-14 | A banner-marked dev persona picker (dev-only, production boot refuses it) and an Entra OIDC seam as the production path (endpoints return 501 until configured).                                                                                                                                                                                             |
| [0005](adr/ADR-0005-ai-provider-gateway.md)                              | AI provider gateway — deterministic mock first-class                                                | Accepted | 2026-07-14 | All model calls go through a typed `AiGateway`; deterministic mock is the default provider, Azure OpenAI and Anthropic adapters are config-gated, no silent cross-provider fallback.                                                                                                                                                                         |
| [0006](adr/ADR-0006-evidence-assertion-provenance.md)                    | Evidence/Assertion model — confirmation never overwrites inference                                  | Accepted | 2026-07-14 | Immutable typed evidence plus versioned assertions; owner decisions create new linked assertions rather than mutating model output.                                                                                                                                                                                                                          |
| [0007](adr/ADR-0007-scan-pipeline-and-stage-caching.md)                  | Durable scan pipeline and stage cache keys                                                          | Accepted | 2026-07-14 | Campaign → partition → work-unit → stage-attempt job hierarchy with versioned stages and explicit stage cache keys and `cache_hit` records.                                                                                                                                                                                                                  |
| [0008](adr/ADR-0008-connector-contract-and-mock-m365.md)                 | Connector contract, deterministic mock M365, live Graph gating                                      | Accepted | 2026-07-14 | Versioned `Connector` interface; deterministic MockM365Connector is the default; the Graph connector is contract-tested on recorded synthetic responses and live execution is config-gated.                                                                                                                                                                  |
| [0009](adr/ADR-0009-analyst-typed-plan.md)                               | Analyst — typed plan over an allowlisted semantic schema                                            | Accepted | 2026-07-14 | Questions compile to a Zod-validated `TypedQueryPlan` over an allowlisted semantic schema; the server treats every plan as untrusted and injects tenant/domain scope itself.                                                                                                                                                                                 |
| [0010](adr/ADR-0010-remediation-state-machine.md)                        | Remediation state machine, idempotency, and drift                                                   | Accepted | 2026-07-14 | `ActionRequest` state machine with separation of duties, idempotency keys, drift detection before execution, and verification by re-reading the source.                                                                                                                                                                                                      |
| [0011](adr/ADR-0011-retention-and-evidence-profile.md)                   | Default retention and evidence profile                                                              | Accepted | 2026-07-14 | Minimized-evidence default; bounded extracted text is retained (a documented deviation) under purpose flags; log redaction is default-deny.                                                                                                                                                                                                                  |
| [0012](adr/ADR-0012-cdx-strategy.md)                                     | CDX tenant strategy for this run                                                                    | Accepted | 2026-07-14 | Deterministic fixtures are the primary validation path; live CDX work is deferred behind `FMD_CONNECTOR_MODE` gates with honest per-capability `blocked` states.                                                                                                                                                                                             |
| [0013](adr/ADR-0013-live-graph-connector.md)                             | Live Microsoft Graph connector (read-only) validated against CDX                                    | Accepted | 2026-07-14 | A read-only, app-only client-credentials `GraphM365Connector` is built and validated live against the CDX tenant; mock stays the default and CI path.                                                                                                                                                                                                        |
| [0014](adr/ADR-0014-roadmap-features.md)                                 | Roadmap features — connectors, notifications, model governance, embeddings, control plane           | Accepted | 2026-07-14 | Six recorded decisions for the eight roadmap features: verified-hint change notifications, Teams/Exchange modalities, human-approved model releases, deterministic-default embeddings, allowlisted control-plane telemetry, secret-free Azure IaC.                                                                                                           |
| [0015](adr/ADR-0015-commercial-web-and-control-plane.md)                 | Commercial web ecosystem, human/machine plane separation, licensing                                 | Accepted | 2026-07-15 | Applies update-001: two never-merged planes (customer content vs. vendor commercial/control), distinct human-Account/machine-Control/in-product identities, no `app.` host, versioned Ed25519/HMAC entitlements with non-destructive expiry, minimal marketing page + Cloudflare runbook (no live changes).                                                  |
| [0016](adr/ADR-0016-vendor-control-plane-service.md)                     | Vendor control-plane service (activation, entitlement signing, renewal, fleet health)               | Accepted | 2026-07-15 | Separate deployable holding the signing key + pseudonymous account registry; signed activation/renewal (supersede + revoke-old), strict-allowlist telemetry ingest, admin-gated account/code management, production key guards; deployment online path verifies returned tokens locally (spoof-safe). Supersedes ADR-0015 §2's "deployment-side only" scope. |
| [0017](adr/ADR-0017-account-portal.md)                                   | Account portal — human commercial front door (registration, self-service evaluation, license views) | Accepted | 2026-07-15 | Human portal over the shared vendor registry: self-service register → 30-day evaluation → one-time code, membership-guarded license/deployment views, opaque sessions + CSRF, dev email auth (production requires IdP), self-contained honest UI. One registry, two views (interop-tested).                                                                  |
| [0018](adr/ADR-0018-entitled-downloads-and-supply-chain.md)              | Entitled downloads + software supply chain (release catalog, signed URLs, checksums, SBOM)          | Accepted | 2026-07-15 | Vendor release catalog (sha256, min-edition, yank) via Control admin; Account mints short-lived HMAC-signed download URLs after membership+active+edition checks; token-auth download endpoint (path-traversal safe, yank-immediate, checksum header); feature-gated + production secret guard; CycloneDX SBOM + SHA256SUMS on every release.                |
| [0019](adr/ADR-0019-support-portal-diagnostic-bundles.md)                | Support portal + consent-based diagnostic bundles                                                   | Accepted | 2026-07-15 | Deployment builds a strict-allowlisted redacted bundle (health bands + error categories, no content); preview-before-send; Account support cases re-validate the bundle against the same strict schema on ingest (boundary holds both ends); membership-guarded (not entitlement-gated), retention-limited, access-logged.                                   |
| [0020](adr/ADR-0020-production-idp-clerk.md)                             | Production identity for the Account portal (Clerk)                                                  | Accepted | 2026-07-15 | Verify Clerk session JWT locally against public JWKS (RS256 pinned, no secret key); JIT-provision by verified email then issue our own session (portal stays IdP-agnostic); config guards; UI loads Clerk only in idp mode. Clerk chosen for DX/free tier (covers SAML too); Entra/WorkOS noted for later.                                                   |
| [0021](adr/ADR-0021-stripe-billing.md)                                   | Stripe billing — paid conversion (Checkout + signed webhooks)                                       | Accepted | 2026-07-15 | Stripe Checkout for payment (PCI stays with Stripe); the signed webhook is the source of truth (HMAC over raw body, constant-time, 300s replay tolerance) and upgrades the account's edition on the shared registry → deployment picks it up on renewal; member-guarded + CSRF checkout; feature-gated; secret key stays in env.                             |
| [0022](adr/ADR-0022-demo-and-dev-environments.md)                        | Demo & dev environments (fail-closed demo mode; Cloudflare Access for dev)                          | Accepted | 2026-07-15 | `FMD_DEMO_MODE` refuses to boot unless safe (mock connector, no live writes/dir-sync), surfaced at `/api/ready` + a UI banner; disposable synthetic demo reset on a schedule; `dev.` gated by Cloudflare Access + Tunnel (runbook). Neither touches customer data or the commercial plane.                                                                   |

## ADR summaries

### [ADR-0001 — Runtime, language, and repository layout](adr/ADR-0001-runtime-language-repo-layout.md)

TypeScript everywhere (server, worker, web, shared contracts) on **Bun 1.3.x** as runtime and toolchain, with Hono for the HTTP API, Zod for runtime validation, React + Vite for the web app, and a Bun-workspaces monorepo (`packages/shared`, `packages/server`, `packages/web`). The driver was a credential-free single-session build on a machine with no Node.js: Bun's native `bun:sqlite` and built-in test runner remove native-module and toolchain friction. The accepted tradeoff is a Bun dependency for local development; the escape hatch is deliberate — Hono's web-standard APIs and a thin DB layer make a move to Node 22 + `better-sqlite3`/Postgres mechanical if a customer environment mandates it.

### [ADR-0002 — Modular monolith + in-process durable worker](adr/ADR-0002-modular-monolith-and-worker.md)

One deployable server process hosts both the HTTP API and a durable worker loop consuming a SQLite-backed work queue with leases, heartbeats, retry classes, and dead-lettering; internal modules (kernel, identity, governance, aigateway, connectors, scan, intelligence — including features, inference, clusters, risk, findings, and the graph/search projections — review, analyst, actions) have explicit public surfaces and no cross-module table access. This buys restart-safe scan durability without distributed-system overhead the slice could not validate. The tradeoff is deferred horizontal scale: the `WorkQueue` contract is the seam (Azure Service Bus is the documented scale path), the worker can already run as a separate process (`bun run worker`) unchanged, and the revisit trigger is a second connector with real throughput or a multi-tenant pilot.

### [ADR-0003 — One SQLite database behind role-specific store interfaces](adr/ADR-0003-persistence-single-sqlite-behind-role-interfaces.md)

A single SQLite file (WAL mode) is the only physical store, but code reaches it exclusively through logical store roles that mirror the reference architecture's polyglot model: per-module repositories, `ObjectStore` (filesystem artifacts), a search projection (FTS5 `search_fts`), a graph projection (typed `graph_nodes`/`graph_edges` tables with bounded traversal), `WorkQueue` + outbox, and a fingerprint index. Every domain table carries a non-null `tenant_id` and every query is written tenant-scoped. The consequence is real semantics (durable jobs, rebuildable projections with watermarks) with credential-free tests; the tradeoff is single-writer concurrency, acceptable at slice scale, with per-role exit criteria in the [capacity model](capacity-model.md).

### [ADR-0004 — Authentication: explicit dev identity mode, Entra OIDC seam](adr/ADR-0004-authentication-dev-mode-and-entra.md)

Authentication has two modes behind `FMD_AUTH_MODE`: a dev identity mode (a persona picker over seeded principals, active only with `FMD_ENV=development` and `FMD_AUTH_MODE=dev`, marked by an `x-fmd-auth-mode: dev` response header and a non-dismissable UI banner) and Entra OIDC (`FMD_AUTH_MODE=entra`) as the production path. Honestly stated: the OIDC code flow is **not implemented** in this build — the Entra endpoints return an explicit `501 not_configured` until an app registration is supplied, and readiness reports the capability as `blocked`/`disabled`, never live. The key safety consequence is the tested production lockout — `FMD_ENV=production` refuses to boot in dev auth mode — and identity mapping by immutable `(issuer, subject)` (unique index), never email; the payoff is that tests exercise the full authorization stack through dev identities without weakening production paths.

### [ADR-0005 — AI provider gateway: deterministic mock first-class](adr/ADR-0005-ai-provider-gateway.md)

All model calls go through `AiGateway` as typed task-level requests (never raw prompt passthrough). The deterministic, content-hash-seeded `MockAiProvider` is the default and the only provider tests use; `AzureOpenAiProvider` and `AnthropicProvider` are first-class but config-gated adapters, not validated live by this ADR. The gateway owns the governance surface: versioned prompt templates, Zod-validated structured outputs with bounded retry, budgets, redaction before egress, untrusted-content delimiting, and audit metadata. The load-bearing tradeoff is **no silent cross-provider fallback** — a failed provider surfaces an explicit `ProviderUnavailableError` rather than switching to a differently governed provider — in exchange for every AI answer carrying `(provider, model, templateVersion)` provenance.

### [ADR-0006 — Evidence/Assertion model: confirmation never overwrites inference](adr/ADR-0006-evidence-assertion-provenance.md)

The handoff data model is implemented directly: immutable typed `evidence` rows (with direction, weight, provenance, bitemporal times) and versioned `assertions` (subject–predicate–object claims with calibrated confidence and provenance kinds from `Observed` through `RemediationVerified`). Owner decisions create **new** `OwnerConfirmed` or corrective assertions linked via `confirms_assertion_id`/`corrects_assertion_id`/`rejects_assertion_id`; the original model-inferred assertion is superseded but never mutated, preserving human ground truth for model evaluation. Distinct value states (`unlabeled`, `unknown`, `unsupported`, `inaccessible`, `partial`, `failed`) are real enum values, never null-collapsed. The cost is append-mostly storage growth, kept queryable via a partial index on `status = 'candidate'`.

### [ADR-0007 — Durable scan pipeline and stage cache keys](adr/ADR-0007-scan-pipeline-and-stage-caching.md)

Scans run as `scan_campaigns → scan_partitions → work_units → stage_attempts` with leases, visibility timeouts, typed retry classes, and dead-letter rows; the pipeline stages (observe through review) are versioned independently. The central decision is the **stage cache key** — designed as `(tenantId, assetId, sourceVersionId, stageName, stageImplVersion, configVersion, inputArtifactHashes)` — with explicit `cache_hit` records, so tests can assert that a new model release, a permission-only change, or a rename re-runs only the stages it must. Pause/resume/cancel operate at stage boundaries and stage re-runs are idempotent by unique index. The consequence is that the invalidation matrix is implemented by the stage cache keys in `scan/pipeline.ts` and locked by scenario tests in `scan.test.ts`; progress reporting uses known/estimated counts, never fabricated percentages.

### [ADR-0008 — Connector contract, deterministic mock M365, live Graph gating](adr/ADR-0008-connector-contract-and-mock-m365.md)

A versioned `Connector` interface (probe, discoverScopes, paged inventory, changes, permissions, label state, bounded content, item-version reads for drift checks, preview/execute label actions) returns canonical observation envelopes with acquisition identity and completeness. The default `MockM365Connector` is a deterministic fixture tenant with paged inventory, scripted delta sequences, `410 resync`, and duplicate/out-of-order delivery for tests. At the time of this ADR the `GraphM365Connector` was compiled and contract-tested against recorded synthetic responses only — live execution explicitly not claimed (later validated read-only in [ADR-0013](adr/ADR-0013-live-graph-connector.md)). Label-catalog listing gets its own seam (fixture import today) because the Graph API for it remains beta. The honest-observability consequence: "no broad share observed" is never treated as "no broad share exists," and Teams/Exchange got contracts and backlog entries rather than fake depth.

### [ADR-0009 — Analyst: typed plan over an allowlisted semantic schema](adr/ADR-0009-analyst-typed-plan.md)

Natural-language questions compile to a Zod-typed `TypedQueryPlan` over an allowlisted semantic schema of named entities, fields, filters, and measures — never tables or SQL. Two planner paths (a pattern-based deterministic planner (`deterministicPlan`) used in tests, and an AI planner behind the gateway) produce the same plan type, and the server treats plans from **either** source as untrusted: it re-validates identically and injects tenant and domain-scope predicates from the authenticated session, which the plan cannot set or widen. Scanned content shown to any AI planner is delimited as untrusted data, and action drafting is a separate, explicitly invoked tool producing `Draft` actions only. The consequence is reproducible, evidence-cited answers and fail-closed injection tests in which hostile fixture content attempts scope-widening and tool escape.

### [ADR-0010 — Remediation state machine, idempotency, and drift](adr/ADR-0010-remediation-state-machine.md)

`ActionRequest` moves through `Draft → Validated → AwaitingApproval → Approved → Executing → Verifying → Succeeded | PartiallySucceeded | Failed | Cancelled | Drifted`, enforced in one module and by DB constraints. Separation of duties (requesters cannot approve their own actions), idempotency on `(tenantId, idempotencyKey)` (duplicate queue delivery performs exactly one source mutation), drift detection (source version changed since preview → `Drifted`, re-preview required), and verification by re-reading the source (executor success text alone never marks success) are all tested. Honest capability split: the internal remediation task type is fully live; the Purview label adapter is real but **live execution is config-gated** on protected/metered API enablement, with the blocked state reported as such. No rollback guarantee is implied for label operations.

### [ADR-0011 — Default retention and evidence profile](adr/ADR-0011-retention-and-evidence-profile.md)

The default is the minimized-evidence profile: IDs/versions, safe metadata, label GUID state, permission summaries, fingerprints, detector hits, and bounded redacted excerpts are persisted; full originals, credentials, and raw prompts are not. The ADR's headline tradeoff is a **documented deviation from the handoff**: bounded extracted text (capped at 20,000 characters by `extract.ts` `DEFAULT_LIMITS.maxOutputChars`) *is* retained as a `sensitive_derivative` artifact, because the "re-score without source I/O" invariant requires a text representation for detector re-runs. Mitigations are purpose-flag enforcement in `ObjectStore.get` (`content_use_flags`, absence means denied) and default-deny log redaction verified by a log-scan test; envelope encryption and per-class TTLs are explicit backlog, also flagged in the [threat model](threat-model.md). The enhanced-evidence profile is schema-ready but not implemented beyond config validation.

### [ADR-0012 — CDX tenant strategy for this run](adr/ADR-0012-cdx-strategy.md)

For the initial build run, deterministic fixtures were the primary validation path and live CDX work was deferred: tenant setup has high latency and uncertainty and changes none of the domain logic being proven. What shipped regardless of live access: the Graph connector with contract tests over recorded synthetic responses, the executable [CDX test runbook](cdx-test-runbook.md), the [permissions manifest](permissions-manifest.md) documenting every requested Graph permission and its caveats, and feature gates (`FMD_CONNECTOR_MODE=mock|graph`) that surface an explicit `blocked` status when prerequisites are absent. The consequence is the honesty invariant in operational form — no fake live integration; UI and STATUS report mock vs. live per capability — and a mock delta sequence designed so the same assertions could later be re-run against CDX.

### [ADR-0013 — Live Microsoft Graph connector (read-only) validated against CDX](adr/ADR-0013-live-graph-connector.md)

A follow-up session with authenticated CDX access built the live path deferred by ADR-0012: `GraphM365Connector` behind the existing contract, app-only client-credentials auth with the secret resolved by reference (`FMD_GRAPH_CLIENT_SECRET_REF`), site/drive discovery, `driveItem` delta feeds with opaque cursors and safe `410` resync, canonical permission mapping with honest `not_observable` completeness, bounded content streaming, and `Retry-After` throttling — **validated read-only against the Contoso CDX tenant**. Honest limits: `getLabel` returns `unknown` (the protected `extractSensitivityLabels` API was not granted), writes return `unsupported`, and `Sites.Read.All`/`Files.Read.All` were used for the pilot with `Sites.Selected` as the least-privilege follow-up. The payoff that fixtures could not deliver: live data surfaced and fixed a real over-classification defect (generic evidence creating type-specific candidates), bumping `INFER_IMPL_VERSION` with retained features re-scoring without source I/O. The mock stays the default and CI-tested path; still unvalidated live are change-sequence deltas, Purview label read/write, Teams/Exchange, and write actions.

### [ADR-0014 — Roadmap features: connectors, notifications, model governance, embeddings, control plane](adr/ADR-0014-roadmap-features.md)

A composite ADR recording six decisions behind the eight roadmap features, all mock-first and honestly gated for live use. (1) Graph change notifications are treated as *hints*: an HMAC-derived `clientState` is verified in constant time before any side effect, then the scope is reconciled through the existing delta feed; without a public HTTPS `notificationUrl` the path is honestly `blocked` and a dev simulator exercises the flow. (2) Teams channel files canonicalize to the backing SharePoint `driveItem` (no second identity), while Exchange attachments — copies — are recorded as **similarity, not identity** (`attachment_similar_to` edges), avoiding a risky rework of the FK-heavy `physical_assets` table; both live paths are feature-flag- and consent-gated. (3) Model releases require human approval with separation of duties enforced in code, over cluster-aware train/eval snapshots — nothing material auto-applies. (4) Embeddings default to a deterministic, credential-free feature-hashing provider with cosine-LSH candidate retrieval and per-model namespaces; Azure OpenAI is config-gated and Anthropic honestly reports no first-party embeddings. (5) The control plane carries licensing and health **bands** only, behind a `.strict()` Zod schema that fails closed on extra fields, with a customer-visible transparency endpoint and opt-out. (6) `infra/main.bicep` keeps secrets out of the template via Key Vault placeholders set out-of-band and managed-identity resolution. Several of these were validated against the live CDX tenant (admin probe, change-notification reconcile, model-release loop, 172 semantic embeddings); the rest run mock-first with gated live paths — see the [roadmap](roadmap.md) for what comes next.

### [ADR-0015 — Commercial web ecosystem, human/machine plane separation, licensing](adr/ADR-0015-commercial-web-and-control-plane.md)

Applies handoff update-001 (commercial web, account, licensing, support, demo, Cloudflare) while reinforcing the core invariant: customer content never leaves the customer boundary to license or support the product. Seven decisions. (1) Two planes never merge — the customer content plane (this installed platform) vs. the vendor commercial/control plane — enforced in code by the strict-allowlisted telemetry schema and a control surface limited to licensing + coarse health. (2) Three distinct identity contexts: human Account (`account.`), machine Control (`control.`), and in-product capability roles; a human portal token is never the license credential. (3) No `app.findmydata.io` — the product is customer-hosted with a customer-controlled URL; the host is reserved, not built. (4) Entitlements are signed by Control and verified locally, dual-algorithm by token header — **Ed25519** for production (deployment holds only the public key) and **HMAC** for dev — versioned, with a rotating `kid`, edition, feature flags, capacity meters, term, connector families, and offline/renewal policy. (5) Non-destructive expiry: past the grace window, new licensed operations are gated (`LicenseError` 402) but the customer's own data, audit, and export stay available; unlicensed is permissive. (6) Marketing/web surfaces are documented and minimally scaffolded (one static landing page) so they never displace core work; Account/Support/Demo are design-only. (7) Cloudflare is a documented, reversible, inspect-first runbook — no unattended live changes, registrar/DNSSEC/email posture preserved. The deployment-side licensing is built and tested (Ed25519 verify-with-public-key-only, activation/renewal, enforcement matrix); the vendor-hosted services holding the signing private keys were out of this repository's scope until ADR-0016.

### [ADR-0016 — Vendor control-plane service (activation, entitlement signing, renewal, fleet health)](adr/ADR-0016-vendor-control-plane-service.md)

Builds the vendor Control service ADR-0015 left as deployment-side-only, closing the online activation/renewal loop. Seven decisions. (1) A separate deployable (`bun run --cwd packages/server control`, entrypoint `control-main.ts`) with its own port and its own **isolated database** (inline schema in `vendor/store.ts`, never the customer migration set); it holds the signing private key and the pseudonymous registry the deployment must not. (2) The content boundary is preserved by construction — telemetry ingest re-validates the same `.strict()` `FleetTelemetrySchema` before storing, and activation/renewal carry only codes/ids/tokens. (3) Keys and the admin credential come only from the environment by reference; production **refuses** HMAC, an ephemeral key, both-keys-set, or a missing admin credential. (4) Activation validates the code (revocation, expiry, activation limit, tenant binding), enforces the account's deployment ceiling, and returns a signed entitlement built from the account plan (edition presets + overrides). (5) Renewal re-issues with an extended term, `supersedes` the prior token, and revokes it (replay resistance), reading the current plan. (6) The deployment never trusts a Control response blindly — `activateOnline`/`renewOnline` verify the returned token against the deployment's own public key and re-bind it to the tenant, so a spoofed Control cannot inject a license; online renewal falls back to a local re-verify on outage. (7) The admin surface (account/code management) is Bearer-gated with constant-time comparison and returns 503 when no admin key is configured; activation-code plaintext is shown once and stored only as a hash.

### [ADR-0017 — Account portal: human commercial front door](adr/ADR-0017-account-portal.md)

Builds the human portal (`account.findmydata.io`) ADR-0015 left as Stage-B design, on top of the Control registry ADR-0016 built. Five decisions. (1) A thin human layer over the SAME vendor database — the commercial org is the existing `cp_accounts` row; the portal adds `portal_users`, `org_members`, server-side `portal_sessions`, and a human evaluation record, and reuses `createAccount`/`createActivationCode` so there is one registry viewed two ways (machine API + human portal), proven by an interop test. (2) Human identity is distinct from the machine Control credential — opaque high-entropy server-side sessions, per-session CSRF on state changes, and membership-guarded org views (404, no existence leak). (3) Dev auth is email-only (reference); production refuses `dev` mode and `idp` mode disables dev-login/register (501) pending an external IdP — the honest state. (4) The content boundary is unchanged: the portal knows emails + org names only, and reads licensing facts (plan, deployments + coarse status, code metadata, control events); codes are shown once, listings return metadata only. (5) The UI is one self-contained, brand-consistent page that states plainly the product installs in the customer's own environment — the portal manages licensing only.

### [ADR-0018 — Entitled downloads + software supply chain](adr/ADR-0018-entitled-downloads-and-supply-chain.md)

Builds the download step of the customer journey (web design §7) on the Account portal + Control registry. Five decisions. (1) A release catalog (`cp_releases`: version, channel, sha256, size, min-edition, yank, notes) lives in the shared vendor DB, registered/listed/yanked through Bearer-admin Control endpoints; the sha256 is validated hex at registration. (2) Account is the authoritative decision point — a short-lived HMAC-signed URL (`b64url(claims).b64url(mac)`, claims = version+accountId+exp, default 15 min) is minted only after membership + account-active + edition-satisfies checks; the download endpoint re-checks signature/expiry and re-reads the release so a **yank is immediate even for already-minted URLs**. (3) The endpoint never trusts a path — it serves `join(dir, basename(artifactName))`, so a catalog entry can't escape the downloads directory; a missing file is an honest 404, and `x-checksum-sha256` is returned for client verification. (4) Feature-gated on `FMD_DOWNLOADS_DIR`; production refuses to enable downloads without an explicit URL-signing secret (an ephemeral per-process secret would break multi-replica mint/verify). (5) Supply chain — the release workflow attaches a CycloneDX 1.5 SBOM (`scripts/sbom.ts`, full Bun install tree) and a `SHA256SUMS` over every artifact, so a customer can verify provenance + integrity end to end.

### [ADR-0019 — Support portal + consent-based diagnostic bundles](adr/ADR-0019-support-portal-diagnostic-bundles.md)

Builds the support surface (web design §8), completing the customer lifecycle. Five decisions. (1) The diagnostic bundle is built INSIDE the deployment (`support/diagnostics.ts`) to the telemetry discipline — a `.strict()` allowlist of non-secret config + health **bands** + error **categories** (`RETRY_CLASSES`) with banded counts, pseudonymous deployment id, no content/names/paths/identities/secrets/messages; a `SENSITIVE_FIELD_DENYLIST` guard test backs it. (2) Preview-before-send: `GET /api/support/diagnostics/preview` returns the exact bundle a support upload would contain, generated locally with nothing sent; uploading is a separate explicit action. (3) The vendor **re-validates the bundle against the same strict schema on ingest**, so customer content can never be stored even if a deployment is compromised — the boundary holds on both build and ingest. (4) Support routes are membership-guarded (404 for non-members, no existence leak) with CSRF, but NOT entitlement-gated (support must work for expired accounts); stored bundles are retention-limited and reads are access-logged. (5) The build implements clean contracts (case lifecycle, consent bundle, retention, access log) so a capable ticketing/KB platform can be integrated behind them without changing the customer-facing model.

### [ADR-0020 — Production identity for the Account portal (Clerk)](adr/ADR-0020-production-idp-clerk.md)

Implements the `idp` auth mode ADR-0017 left as a 501 stub. Four decisions. (1) Clerk, verified LOCALLY against its public JWKS — the portal accepts a Clerk session JWT and checks RS256 signature by `kid`, issuer, exp/nbf, and `azp`; only Clerk's public keys are used, so no Clerk secret key is held/logged/committed. Clerk chosen for the portal's DX + free tier and because the operator uses it; it also supports SAML enterprise connections, so it doesn't foreclose Entra External ID / WorkOS later. (2) JIT-provision the portal user by verified email, then issue our OWN opaque session — the rest of the portal (CSRF, membership guards, org views) is unchanged and IdP-agnostic; a token without a verifiable email is rejected (never guess identity from `sub`). (3) `idp` mode requires public `FMD_CLERK_ISSUER` + `FMD_CLERK_PUBLISHABLE_KEY`; production refuses dev auth; dev self-service routes 501 in idp mode and `/session/idp` 501s in dev mode. (4) The UI loads Clerk.js only in idp mode (derived from the publishable key); dev mode stays self-contained. The verifier is the whole trust boundary, so it pins RS256 (rejects alg:none/HS256), checks the signature before any claim, and is covered by forgery/tamper/expiry/azp/alg tests + adversarial review.

### [ADR-0021 — Stripe billing: paid conversion](adr/ADR-0021-stripe-billing.md)

Completes the commercial loop — an evaluation converts to a paid subscription without reinstalling. Four decisions. (1) Stripe Checkout handles payment (PCI stays entirely with Stripe; the portal never sees card data); the entitlement change happens ONLY on the verified webhook, never on the redirect — the account id + edition ride as client\_reference\_id + metadata. (2) The webhook signature is the trust boundary: `billing-stripe.ts` implements Stripe's scheme itself (no SDK) — HMAC-SHA256 over the exact raw body, constant-time compared (length-guarded) against any v1 signature, with a 300s timestamp tolerance (replay); the route reads the raw body and verifies BEFORE parsing/acting, so an account changes plan only via a genuine Stripe event. (3) A verified upgrade maps to the existing plan model (`updateAccountPlan(planForEdition(edition))` + store the Stripe ids); `customer.subscription.deleted` reverts to evaluation; re-deliveries are idempotent and verified events always 200 (only signature failures 4xx). (4) Checkout is member-guarded + CSRF with the price resolved server-side from the edition; billing is feature-gated (needs both secrets + a price map); the Stripe secret key stays in the environment and pricing amounts live in Stripe, not the code.

### [ADR-0022 — Demo & dev environments](adr/ADR-0022-demo-and-dev-environments.md)

Makes the public demo safe by construction and specifies dev protection. Four decisions. (1) Demo mode is a fail-closed product flag (`FMD_DEMO_MODE`) — the deployment refuses to boot unless the connector is `mock` (synthetic data, never a live tenant) and live label writes + directory sync are off; so a misconfigured demo can't reach a real tenant or write back. `demoMode` is surfaced at `/api/ready` and the web UI shows a persistent demo banner. (2) The demo is disposable — synthetic-seeded, reset on a schedule (wipe + migrate + seed), hosted on the existing Azure Container Apps IaC with an ephemeral volume + Turnstile abuse control; distinct from disposable CDX engineering resources. (3) `dev.findmydata.io` is gated by Cloudflare Access (Zero Trust, SSO/MFA, free ≤50 users) in front of a Cloudflare Tunnel (no public inbound ports), blocked by default — a runbook, not repo code, applied reversibly and logged in the Cloudflare runbook. (4) Neither environment holds customer data or is part of the vendor commercial plane, preserving the content + plane-separation invariants.

### [ADR-0023 — Plain support mirror](adr/ADR-0023-plain-support-mirror.md)

Wires the first real ticketing platform (Plain) behind the ADR-0019 support contracts. Four decisions. (1) The mirror is best-effort and runs OFF the response path — the case commits and returns 201 first, then a guarded background task opens the Plain thread; `forwardCaseToPlain` never throws, so a slow/hung/erroring Plain can never delay or fail case creation (it replaced an earlier inline `await` that put \~10–20s of Plain latency on the critical path). (2) Only case metadata crosses — opener email+name, subject, severity, portal case id; the thread body is a fixed content-free summary and the **org display name is not sent** (only the opaque account id). (3) A least-privilege machine-user key (`customer:create`, `customer:edit`, `thread:create`) resolved by reference (`FMD_PLAIN_API_KEY_REF`, unset ⇒ off) against Plain's global host `core-api.uk.plain.com` — a live end-to-end test caught the wrong hardcoded host before release. (4) GraphQL responses are parsed defensively (optional-chained mutation fields fall through to the real `errors[]` message instead of a mislabeled `TypeError`; non-2xx classified by status). Live-verified; four adversarial-review findings fixed; endpoint idempotency noted as a cross-cutting follow-up.

### [ADR-0024 — Org member management](adr/ADR-0024-org-member-management.md)

Lifts the single-owner limitation ADR-0017 left, so a real commercial org can have teammates. Four decisions. (1) Two roles (`owner` | `member`) — members view, owners manage — with a service-enforced invariant that **an org always keeps ≥1 owner** (the last owner can't be removed or demoted → 409), so no org is orphaned. (2) Invitations are an email allowlist (`org_invites`, keyed by account + lowercased email, upsert-idempotent) that **auto-accepts on every sign-in** (`issueSession` → `acceptInvitesForEmail`) — composing with Clerk JIT (provision by verified email, then accept) and dev auth; an invite only adds to the **inviter's own** org, and `addMember` never downgrades an existing higher role. (3) Every route is membership-guarded (non-member → 404, no existence leak), mutations are owner-only + CSRF, and each target is bound to the same `accountId` (no cross-org IDOR); mutations record PII-light control events (role/`"ok"`, never the raw email). (4) No new customer content — invites/membership stay in the portal identity plane. 11 tests + adversarial review.
