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/, 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 that seeded this build uses three levels of commitment:
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.
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
ADR summaries
ADR-0001 — Runtime, language, and repository layout
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
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: theWorkQueue 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
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.
ADR-0004 — Authentication: explicit dev identity mode, Entra OIDC seam
Authentication has two modes behindFMD_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
All model calls go throughAiGateway 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
The handoff data model is implemented directly: immutable typedevidence 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
Scans run asscan_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
A versionedConnector 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). 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
Natural-language questions compile to a Zod-typedTypedQueryPlan 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
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
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 byextract.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. The enhanced-evidence profile is schema-ready but not implemented beyond config validation.
ADR-0012 — CDX tenant strategy for this run
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, the permissions manifest 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
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
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-derivedclientState 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 for what comes next.
ADR-0015 — Commercial web ecosystem, human/machine plane separation, licensing
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)
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
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
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
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)
Implements theidp 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
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
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
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
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.