Skip to content

Architecture

bank LAN ──443──► app-ui (nginx) ──► api (.NET 10) ──► db (PostgreSQL / SQL Server)
│ /api, /mcp │ ├──► inference (CPU, OCR)
│ │ ├──► documents volume (SHA-256 addressed)
│ │ ├──► exports volume · watch volume
│ └── outbound only, each optional: core (jXchange) · SMTP · OIDC IdP · metering · template-suggestion model
  • One published port. app-ui serves the workstation and proxies /api and /mcp to api. TLS terminates at the bank’s load balancer or a certificate-bearing proxy in front of app-ui. The api trusts X-Forwarded-* only from Proxy__TrustedNetworks.
  • Everything runs inside the bank’s network. Documents are processed by the inference container on CPU; nothing about a loan is sent to Bookend or to any hosted service. The outbound connections above are configured by the bank and can each be left off; air-gapped mode turns off metering entirely.
  • Layered .NET host. The api is split into Domain, Application (services, validation, rules engine), Infrastructure (persistence, jobs, mail, tokens, evidence, adapters) and the HTTP host. Dependency direction is enforced by project references and an architecture test suite.
  • Frontend. React 19 built with Vite and served as static files by nginx, with a same-origin Content Security Policy (default-src 'self', connect-src 'self', frame-ancestors 'none'). It calls /api with types generated from the OpenAPI document.
  • Schema under change control. The schema is hand-written, numbered, forward-only DDL for PostgreSQL and SQL Server, applied by a separate migrator container. The migrator refuses to run when an applied script has been altered (checksum drift), and the api’s readiness probe (/readyz) reports unhealthy until the database is at the schema level the build requires. Nothing alters the schema at application start.
  • Two database principals. An owner account for DDL and grants, used only by the migrator, and a least-privilege application account. The application account cannot update, delete or truncate the evidence chain (evidence_events), the sign-in and API audit (auth_events), reviewer actions (finding_actions) or the data audit log (audit_log), and cannot write the migration ledger. History is append-only at the database level, not only in code.
  • Data audit. Row-level changes on business tables are written to audit_log in the same transaction, with the actor.
  • Evidence chain. Every evidential action appends an event whose hash covers its canonical JSON payload and the previous event’s hash. A nightly job re-verifies every chain, and a packet export verifies the chain again before printing the result.
  • Documents are stored once, addressed by SHA-256, and never modified.
  • Access tokens are RS256 JWTs (15 minutes by default, at most 60) signed with a key generated on first boot and stored encrypted in settings; the public key is published at /v1/.well-known/jwks.json. Refresh tokens rotate on every use and can be revoked. Tokens live in browser memory only: no cookies, no server sessions, nothing in local storage.
  • Sign-in methods, each switchable per bank: email and password (Argon2id), emailed single-use sign-in links, authenticator-app MFA (RFC 6238 TOTP, enforced at password sign-in for enrolled users), and federation through the bank’s OpenID Connect provider (Entra ID, Okta, ADFS and others). OIDC sign-in maps to an existing, active Bookend user; there is no just-in-time provisioning. An email-domain allowlist (auth.allowed_email_domains) restricts which addresses can be added as users.
  • Roles and policies. admin, manager, specialist, boarding_checker and auditor_readonly, checked by named policies on every route. Approvals, overrides, boarding, wires, funding and sealing require a signed-in person, and maker-checker steps refuse the same person twice. See Users and roles.
  • API keys for integrations carry scopes (status:read, intake:write, evidence:read) instead of roles, are shown once and stored as SHA-256 hashes, and are exchanged for short-lived tokens. The MCP server uses the same tokens and policies as REST.
  • Rate limits. A token bucket per principal on the API (300 requests per minute by default) and a much tighter bucket per client address on the credential endpoints (login, sign-in links, API-key exchange, OIDC; 10 per minute by default). Rejections are 429 problem+json responses.
  • Audit of access. Every sign-in, refresh, sign-in link, MFA step, API-key exchange and MCP tool call is written to auth_events with the client address and user agent.

Settings marked secret (SMTP and core passwords, the OIDC client secret, the token-signing key, the license key, the metering and suggestion-model API keys) and each user’s TOTP secret are encrypted with AES-256-GCM under BOOKEND_MASTER_KEY, 32 random bytes the bank generates and holds. The API masks secrets on read.

Background work runs on Quartz.NET with its job store in the application database, so it resumes after a restart: the document pipeline stages (retried with backoff while inference is unavailable), the watch-folder scan, the nightly evidence verifier, the nightly document retention sweep and the daily metering heartbeat.

The api and app-ui send X-Content-Type-Options: nosniff, X-Frame-Options: DENY, a strict Referrer-Policy and a restrictive Permissions-Policy; API responses are Cache-Control: no-store. Errors are RFC 9457 problem+json without stack traces.

Decisions with trade-offs are recorded as architecture decision records.