blocked_capability, unknown, unsupported, blocked), so many of the “errors” below are the product working as designed. Current release: 26.7.20.0, Bun 1.3.14.
Contents: Error envelope · Boot · Auth/session · Connectors · Scans · Change notifications · Teams/Exchange · Model releases · UI · Where to look
Reading API errors
All typed errors are mapped centrally (packages/server/src/kernel/errors.ts, handler in packages/server/src/kernel/http.ts). The JSON body is { "error": <code>, "message": <detail> }.
Boot and configuration
Server refuses to start: FATAL: FMD_AUTH_MODE=dev is not permitted when FMD_ENV=production
- Cause: Production boot with dev identity mode. This is a deliberate security invariant in
buildConfig()(packages/server/src/kernel/config.ts), covered by tests — the process must not boot, full message:FATAL: FMD_AUTH_MODE=dev is not permitted when FMD_ENV=production. Configure FMD_AUTH_MODE=entra with a valid Entra application. - Resolution: Set
FMD_AUTH_MODE=entra. The Entra OIDC code flow is built (ADR-0035) and live-validated; the login endpoints return501 not_configuredonly untilFMD_ENTRA_TENANT_ID,FMD_ENTRA_CLIENT_ID,FMD_ENTRA_CLIENT_SECRET_REFandFMD_ENTRA_REDIRECT_URIresolve (see Auth below and ADR-0004). Do not work around the boot refusal.
Server refuses to start: FATAL: default FMD_SESSION_SECRET is not permitted when FMD_ENV=production.
- Cause:
FMD_SESSION_SECRETis unset (or set to the dev defaultdev-only-change-me) withFMD_ENV=production. - Resolution: Set a strong unique
FMD_SESSION_SECRET. It signs session cookies and derives change-notificationclientStateHMACs — rotating it invalidates all sessions and live subscriptions’ clientState verification. Note the schema also requires a minimum length of 8 characters; a shorter value fails config parsing (ZodError) at boot in any environment.
Migrations fail or the schema looks stale
- Symptoms:
bun run migratethrows a SQLite error; or the server errors on a missing table/column. - Cause: Migrations live in
packages/server/migrations/(0001_identity_governance.sql…0025_attestations.sql) and are applied in sorted filename order, each inside its own transaction, recorded in theschema_migrationstable (packages/server/src/kernel/db.ts). A failing file rolls back only itself; earlier files stay applied. - Resolution:
- Run
bun run migrate(root script →bun src/kernel/migrate-cli.tsinpackages/server). Output is eitherDatabase is up to date.or oneapplied <file>line per migration. - Check
SELECT name FROM schema_migrationsagainst the directory listing to find the first unapplied file; the SQLite error message identifies what failed in that file. database is lockederrors: the DB is opened with WAL mode andbusy_timeout = 5000; stop the API/worker processes that hold the file before migrating. Single-writer SQLite is a known constraint of the default backend (ADR-0003, scale triggers in capacity model); deployments needing more headroom can run the optional PostgreSQL 16 backend (FMD_DB_BACKEND=postgres+FMD_DB_URL), where the same migrations apply and lock contention behaves differently.- Never hand-edit
schema_migrationsto “skip” a failed migration.
- Run
Authentication and sessions
401 authentication_required on every API call
- Cause: No valid session cookie (
fmd_session), an expired/revoked session, or — in production — a dev-mode session:kernel/http.tsrejects sessions withauth_mode = 'dev'at request time even if the cookie is otherwise valid (second layer behind the boot refusal). - Resolution: Sign in again. In dev:
POST /api/auth/dev/loginwith a seededprincipalId(unknown IDs return 400Unknown dev persona). Confirm which mode the server runs viaGET /api/auth/modeor thex-fmd-auth-moderesponse header.
403 {"error":"csrf_token_invalid"} on POST/PUT/PATCH/DELETE
- Cause: Every mutating request from an authenticated session must carry the session’s CSRF token in the
x-fmd-csrfheader; mismatch or absence returns this error (kernel/http.ts). The web UI fetches the token fromGET /api/auth/me(csrfTokenfield) and attaches it automatically. - Resolution: For scripts/curl: call
/api/auth/mefirst and sendx-fmd-csrf: <csrfToken>. If the UI hits this, the session was likely re-issued in another tab — reload so the app refreshes its token. Note: the unauthenticated Graph webhook receivers are exempt (no session, so no CSRF check).
GET /api/auth/dev/personas returns 404
- Cause: Intentional. The persona list and
POST /api/auth/dev/loginonly exist whenFMD_ENV=developmentandFMD_AUTH_MODE=dev; anything else getsNotFoundErrorso the endpoint is not even revealed (packages/server/src/identity/auth-routes.ts). - Resolution: Nothing to fix in production — this is the correct behavior. In dev, check both env vars.
GET /api/auth/entra/login returns 501 not_configured
- Cause: The Entra OIDC flow is built (ADR-0035); 501 means the app registration is not configured. The response message is verbatim
Entra OIDC requires FMD_ENTRA_TENANT_ID, FMD_ENTRA_CLIENT_ID, FMD_ENTRA_CLIENT_SECRET_REF and FMD_ENTRA_REDIRECT_URI. See docs/permissions-manifest.md.(In dev auth mode the endpoint 404s.) - Resolution: Set
FMD_ENTRA_TENANT_ID,FMD_ENTRA_CLIENT_ID,FMD_ENTRA_CLIENT_SECRET_REF(a reference to another env var holding the secret) andFMD_ENTRA_REDIRECT_URI, then retry — the endpoint 302s to the real Entra authorize endpoint. See the permissions manifest.
Connectors (Microsoft Graph and other sources)
The registry never falls back to mock silently:FMD_CONNECTOR_MODE=graph with missing configuration is an explicit 422 blocked_capability (packages/server/src/connectors/registry.ts). The Graph read connector is built and validated read-only against a live Microsoft 365 tenant (ADR-0013); mock remains the default/CI path. The same posture applies to every other source family — Google Drive, Box, Azure Blob/Files, on-prem SMB shares, and SQL databases — each is gated by its own feature flag and configuration, and a missing piece is an explicit blocked_capability naming the exact env vars (see the source-family table below).
422 blocked_capability — which message means what
422 blocked_capability — other source families
The non-Microsoft source families follow the same pattern: flag off → a message naming the flag and its configuration; flag on but credentials/references unresolvable → a follow-up message naming exactly what failed to resolve. All are thrown by the same registry (packages/server/src/connectors/registry.ts) and reported honestly on GET /api/ready:
Connectivity probe fails (POST /api/admin/connector/test)
The probe (capability connector.test; Setup page in the UI) acquires an app-only token and reads one site. It returns ok: false with a safe, low-cardinality detail; it never returns a 500 and never leaks a secret, tenant name, or path:
Every probe is audited (
connector_probe in the config category) with mode, outcome, and detail.
Scans slow down or stall under Graph throttling
- Symptom: Live scans crawl; metrics show
graph_throttled:429/graph_throttled:503counters climbing (GET /api/ops/metrics, Operations page). - Cause: The Graph client honors
Retry-Afteron 429/503 with up to 5 attempts per request, sleeping the server-specified delay capped at 30s (packages/server/src/connectors/graph/client.ts). This is normal backpressure, not a fault. - When it becomes a fault: After 5 throttled attempts the client throws
GraphError("throttled_retries_exhausted", 429). Inside a scan this fails the work unit; the worker retries it with queue-level backoff, and only persistent exhaustion dead-letters it. - Resolution: Usually wait — throttling is per-app/per-tenant on Microsoft’s side. If chronic, reduce concurrent scan campaigns and review the tenant’s Graph usage.
Delta scan triggers a full resync (delta_resync_required in logs)
- Symptom: A delta campaign suddenly does inventory-scale work; log event
delta_resync_required; the scope’s cursor status flips toresync_requiredand itscoverage_statetostale. - Cause: Graph returned 410 Gone for the stored delta token — the connector maps this to
{ kind: "resync_required" }(connectors/graph/connector.ts), and the worker re-baselines inside the same campaign by enqueuing aresync:page:0inventory unit (packages/server/src/scan/worker.ts). - Resolution: None needed — this is the designed self-heal (ADR-0007). Unchanged assets resolve as stage-cache hits, so no duplicate logical work is done (tested). If resyncs recur constantly, the delta cursor is being invalidated upstream (e.g. drive restructuring).
Scans and the work queue
400 No delta cursor for this scope yet — run an inventory scan first
- Cause:
POST /api/scanswithkind: "delta"for a scope that has never completed an inventory scan — a delta cursor only exists after inventory reaches its terminaldeltaLink(packages/server/src/scan/campaigns.ts). - Resolution: Run an inventory campaign for the scope first. (This same condition is why change notifications can report
reconcile_skipped/no_delta_cursor— see below.)
409 A campaign is already active for this scope
- Cause: A campaign in state
pending,running, orpausedalready exists for the scope; one active campaign per scope by design. - Resolution: Wait for it, or cancel it (
POST /api/scans/:id/cancel). If it looks permanently stuck, check the queue (below) — a stuck lease resolves itself. Related conflict:Cannot <pause|resume|cancel> a campaign in state <status>means the transition isn’t legal from the current state (pause only fromrunning, resume only frompaused, cancel fromrunning/paused/pending).
Campaign appears stuck (work units leased, no progress)
- Cause & self-healing: The queue is at-least-once with 60-second leases (
LEASE_MSinpackages/server/src/scan/queue.ts). A crashed worker’s units become leasable again when the lease expires. Additionally, the fleet sweep (packages/server/src/scan/fleet.ts) marks a scannerstaleafter 30s without heartbeat andofflineafter 90s, at which point all its in-flight leases are released immediately for takeover by healthy scanners (tested). - Resolution:
- Check the fleet on the Operations page or
GET /api/ops/overview(fleetarray: status, last heartbeat,leased_now). Anofflinescanner with released work is already handled. - If no scanner is running at all, start one: the API process embeds a worker loop, or run a standalone worker with
bun run worker. - A paused campaign hands out no new leases but does not touch in-flight units; resuming a campaign whose last unit finished while paused completes it immediately rather than stranding it in
running.
- Check the fleet on the Operations page or
Dead letters: triage by retry class
Failed work units are retried with backoff untilmax_attempts (default 4), except terminal classes which dead-letter immediately (packages/server/src/scan/queue.ts). Dead letters carry only a retry_class and a sanitized safe_diagnostic (no names, paths, or content). View them in GET /api/ops/overview (deadLetters, latest 50), per-campaign in GET /api/scans/:id, or the Operations page.
Honesty note: the scan worker’s classifier (
classifyError in packages/server/src/scan/worker.ts) currently assigns only transient, unsupported, and permanent. The throttled, auth, corrupt, and policy_denied classes are defined in the queue’s retry policy (RETRY_CLASSES in packages/shared/src/domain-enums.ts) and behave as described above if assigned, but no scan path emits them yet — sustained throttling or credential failures surface today as transient.
Extraction: “unsupported” vs “failed” vs “inaccessible”
These are distinct, honest observable states onasset_versions.content_state — never a silent skip (packages/server/src/intelligence/extract.ts, packages/server/src/scan/pipeline.ts; per-format counters in metric extraction:<format>:<state>):
not_fetched— content stage hasn’t run for this version yet.available/partial— extracted (partial = truncated at output bounds).unsupported— the connector delivered bytes but no extractor claims the format (e.g. scanned/CID PDFs, true binaries). Honest capability limit; OCR is roadmap (roadmap).failed— an extractor should have handled it but errored, or the file exceeded size bounds (connector-sidemaxBytes, or extractor input limits). Worth investigating, unlikeunsupported.inaccessible— the source returned 403/404 for content; the app can see the item but not read it. Check item-level permissions.
Change notifications
Built end-to-end (subscription lifecycle, unauthenticated receiver with clientState HMAC, dedup, delta reconcile) and validated end-to-end via the dev simulator; live Graph subscriptions additionally require a public HTTPS webhook URL and the feature flag, and are honestlydisabled (flag off) or blocked (flag on, URL/credentials unresolvable) until then (packages/server/src/notifications/subscriptions.ts, GET /api/ready → changeNotifications).
Subscription creation returns 422 blocked_capability
Without a public endpoint, use the development simulator (
POST /api/dev/notifications/simulate, dev-only, 404 elsewhere) which drives the identical receiver + reconcile path with a forced mock subscription.
Webhook returns 202 but nothing happens
POST /api/webhooks/graph/notifications always returns 202 fast regardless of per-item outcome (Graph requires it), so a 202 proves delivery, not acceptance. Check the per-notification outcome in GET /api/admin/notifications/recent and the metrics:
change_notification:rejected:client_state_mismatchclimbing, no notification rows: the clientState HMAC didn’t verify. By design nothing is persisted on mismatch (an unauthenticated caller must not amplify writes); the metric is the only trace. Cause:FMD_SESSION_SECRETchanged after the subscription was created (the HMAC derives from it) — a Graph subscription registered under the old secret keeps posting the old clientState. Fix: recreate subscriptions after any secret rotation.change_notification:rejected:unknown_subscription: Graph posted for a subscription ID this database doesn’t know — typically leftovers from a wiped DB. Delete the orphan subscription in Graph or let it expire.deduplicated: a same-subscription hint arrived within the 10s debounce window; the earlier one already triggered reconcile. Normal.- Lifecycle events with wrong/absent clientState are dropped silently (
packages/server/src/notifications/routes.ts) — also by design.
Notification accepted but reconcile_skipped
The stored reject_reason on the notification row explains it — both common cases are benign (ingestNotification):
Subscriptions expire or show status = 'error'
- Live subscriptions are short-TTL (60 min) and renewed by the periodic sweep when under 20 min remain. A failed renewal sets
status = 'error',last_error = 'renew_failed'(metricchange_subscription_renew_failed); expired ones are swept toexpired. Graph lifecycle events are handled:reauthorizationRequired→ markedexpiringfor the next renew,missed→ a forced delta reconcile,subscriptionRemoved→ markedremoved. - Resolution: trigger
POST /api/admin/notifications/renew, or re-subscribe (POST /api/admin/notifications/subscribereturns the existing subscription unless it isremoved/error, in which case it re-registers). Confirm the API process is running — its embedded worker loop hosts the renewal sweep (packages/server/src/main.ts); the standalonebun run workerdoes not run it.
Teams and Exchange connectors
Both are built and mock-validated; the live Graph paths are feature-gated and have not been validated live (see the STATUS honest feature list). Sync endpoints:POST /api/teams/sync, POST /api/exchange/sync (capability connector.configure); read views require operations.read because messages are not yet mapped to business-domain ownership (deny-by-default).
422 blocked_capability on sync
The four gating messages are listed verbatim in the connector table. Flags: FMD_FEATURE_TEAMS_CONNECTOR, FMD_FEATURE_EXCHANGE_CONNECTOR. Exchange additionally reads only mailboxes listed in FMD_EXCHANGE_MAILBOXES (comma-separated, least-privilege).
Sync fails with a Graph 403 (protected-API consent)
- Cause: Teams channel-message read requires
ChannelMessage.Read.All— a protected/metered Graph API needing Microsoft approval beyond baseline read; Exchange requiresMail.Read(scope it with an application access policy). Without consent, Graph returns 403; the adapters surface it honestly rather than fabricating data (packages/server/src/connectors/teams/graph.ts,.../exchange/graph.ts). - What you’ll see: a hard 403 on the message-listing call aborts the sync — at the API surface this is a 500
internal_errorwith acorrelationId(aGraphErroris not a typed app error). Two spots degrade instead of failing: a 403/404 on a Teams channel’sfilesFolderjust leaves the backing folder unknown, and unreadable Exchange attachments return an empty attachment list. - Resolution: Complete the consent per the permissions manifest, then re-run sync. Verify honest state first in
GET /api/ready→teamsConnector/exchangeConnector:mock,disabled(flag off),blocked(flag on, credentials unresolvable), orlive.
Model releases
The governed release lifecycle (packages/server/src/models/pipeline.ts; UI page /models; API under /api/models/) has been validated end-to-end on live tenant data. Every gate below is intentional:
Web UI
Nav sections are missing / page is blank for a user
Not a bug — the sidebar is capability-composed: theSURFACES registry in packages/web/src/App.tsx drives both nav visibility and the route gates, and the API enforces the same capabilities server-side (deny-by-default; a URL typed directly still gets 403/404):
Deliberate exclusions (see
packages/shared/src/capabilities.ts): platform_admin has no evidence/excerpt/review access, and domain_owner capabilities are domain-scoped, not tenant-wide. So “admin can’t see review items” is the design, not a permissions bug.
Yellow banner: “Development identities — not for production. Signed in as a seeded fixture persona.”
Shown whenever the session’s auth mode isdev. It means anyone with network access can sign in as any seeded persona — fine locally, never acceptable for real data. Production cannot show it: dev auth is refused at boot and dev sessions rejected per-request. If the persona picker itself is missing at login, the server is not in dev mode (the login page checks GET /api/auth/mode and only shows the picker for dev); an empty persona list in dev mode means an unseeded database — run bun run seed.
Where to look (diagnostics)
- Correlation IDs. Every request gets one (or honors a client-supplied
x-fmd-correlationheader). It appears in thehttp_requestlog line for every request, in every audit event, and in 500 bodies. Given a user-reported error with acorrelationId, grep the server logs or the Operations page’s recent-log panel for it. GET /api/ops/overview(capabilityoperations.read; Operations page): work-queue counts by status, latest 50 dead letters (retry class + safe diagnostic), scanner fleet health, andrecentLogs— the last 100 in-process log entries, filtered to your tenant (plus tenant-agnostic system events; other tenants’ entries are never shown). Logs are allowlist-redacting by design: no names, paths, URLs, or content phrases (tested end-to-end) — expect slugs and IDs, and pivot on the correlation ID for detail.GET /api/ops/metrics: counters/histograms (stage latency,extraction:<format>:<state>,graph_throttled:*,change_notification:*, work outcomes) plus DB-derived gauges (queue depth, oldest pending age, delta-cursor lag, content states).GET /api/ready: honest per-integration mode —mock/live/blocked/disabled.blockedmeans the capability cannot go live yet: for the connector, AI provider, Teams/Exchange, and change notifications it is “enabled but credentials (or webhook URL) don’t resolve”; the Purview label rows also reportblockedwhen their feature flag is simply off, andentraSignInreportsdisabledin dev auth mode,blockedwhenFMD_AUTH_MODE=entrabut the Entra app-registration env vars don’t resolve, andliveonce they do. If you expectedlive, start here.GET /api/health: liveness only ({"status":"ok"}).- Audit trail (
GET /api/audit, capabilityaudit.read; Audit page): hash-chained per-tenant events, filterable by category. Authorization denials land here asauthorization_deniedwith the missing capability inpolicy_decision— the fastest way to answer “why was this user blocked”. Scan, config, model, and action lifecycles are all audited with correlation IDs. - Per-campaign detail:
GET /api/scans/:idreturns work-unit counts by status, stage outcomes (computed / cache_hit / skipped / failed per stage), and that campaign’s dead letters.
Find My Data release 26.7.20.0 · Bun 1.3.14.