Skip to content

locus-backend (SoR) ​

ASP.NET Core 10 · EF Core 10 (Npgsql.EntityFrameworkCore.PostgreSQL) · PostgreSQL · port 5000 · project Locus.Api.

The backend is the system of record of the platform. The Next.js apps (locus-web, locus-ops) are BFFs that forward a session-derived Bearer token; Python workers compute but never persist tenant truth.

Responsibilities ​

  • PostgreSQL system of record for all tenants + platform legal vault metadata (tables without OrganizationId).
  • AuthSessions, institutional RBAC (InstitutionalAuthorization; six core tiers PLATFORM_OPERATOR … SYNDICATE_GUEST plus expanded desk roles in InstitutionalRole), audit hash chain, email outbox.
  • Analysis job orchestration → Python worker (best-effort ping) + SoR-backed DossierAnalyticsService.
  • Optional Watchtower hosted service (LEGAL_VAULT_WATCHTOWER_INTERVAL_MINUTES).
  • Server-side PDF / Excel exports (QuestPDF / ClosedXML).
text
 locus-web / locus-ops (BFF)
      │  Authorization: Bearer <sessionId>   X-Organization-Id (from session)
      ▼
 InstitutionalAuthMiddleware ──► TenantRlsMiddleware ──► Controller
      │ AuthSessions lookup           │ GUCs: app.current_tenant_id /
      │ (IgnoreQueryFilters)          │       app.is_platform_operator
      ▼                               ▼
 ClaimsPrincipal (sub, tenant_id,   EF global query filters + FORCE RLS
 role, session_id)                  (OrganizationId + dossier-child policies)

Startup ​

  1. Require ConnectionStrings:DefaultConnection (no committed DB password; env / user-secrets only).
  2. Redis for the analysis-job store in Production (ConnectionStrings__Redis or REDIS_URL) — or emergency in-memory flag (LOCUS_ALLOW_INMEMORY_QUEUE=1). IAnalysisJobStore → RedisAnalysisJobStore or MemoryAnalysisJobStore.
  3. MigrateAsync; seed only if Seed:RunOnStartup=true (default off in production; Dev-only POST /api/admin/seed for rare manual loads).
  4. CORS from Cors:Origins (bank :3000 + ops :3001).
  5. OpenAPI / Scalar in Development.
  6. LegalVaultWatchtowerHostedService registered; it no-ops unless the interval env is a positive integer.

Controllers (route inventory) ​

Routes below are taken from the [Route] / [Http*] attributes in locus-backend/Controllers/.

ControllerBase routeEndpointsPermission (typical)
AuthControllerapi/authPOST register, POST login, POST platform-login, POST otp, POST logout, GET sessionpublic (pre-session) / session
DealsControllerapi/dealsGET, GET {id}, POST, PUT {id}/status, POST {id}/archive, POST {id}/unarchive, POST {id}/cells/{cellId}/override, GET {id}/reconciliationdossier:*, financial:read, financial:override
DocumentsControllerapi/documentsGET, POST, GET {id}, GET {id}/pages, POST {id}/verify-citation, POST {id}/ocr-resultdealroom:upload, dealroom:read_approved
AnalysisJobsControllerapi/analysisjobs + ~/api/deals/…POST /api/deals/{dealId}/analysis, GET /api/analysisjobs/{jobId}, GET /api/deals/{dealId}/jobsdossier:write / dossier:read + deal ownership
RegulatoryFindingsControllerapi/deals/{dealId}/regulatory-findingsGET, PUT (replace-all)regulatory:read / dossier:write
LegalVaultControllerapi/legal-vaultGET resolve, GET artifacts/{id}, GET artifacts/{id}/contentregulatory:read (read-only, no tenant writes)
RegulatoryAdminControllerapi/admin/regulatoryoverview, sources, instruments, amendments, applicability, editions, artifacts, review, logs (see Legal vault)system:manage (PLATFORM_OPERATOR)
MarketControllerapi/marketPOST ingest, GET overviewfinancial:write/tenant:manage; financial:read
MembersControllerapi/membersGET, GET roles, GET audit, GET {id}, POST invite, POST {id}/resend, POST accept-invite, PATCH {id}, DELETE {id}tenant:manage
OrganizationsControllerapi/organizationsGET current, PUT current/profile, PUT current/edition, PUT current/entitlementstenant:manage; regulatory module cannot be disabled
AccessRequestsControllerapi/access-requestsGET, GET {id}, POST, GET/POST verify, POST {id}/review, POST {id}/provisionpublic submit/verify; system:manage review/provision
ExportsControllerapi/exportsPOST memo, syscohada, ratios, ratios/summary, contradictions, risks, cosumaf-attestationper export
AlertsControllerapi/alertsGET, POST {id}/assign, {id}/acknowledge, {id}/archivefinancial:read
PortfolioControllerapi/portfolioGET book; CRUD holdings, instruments, issuersfinancial:read / write
QaControllerapi/deals/{dealId}/qaGET, POST, PATCH {threadId}qa:*
SearchControllerapi/searchGET (ILIKE + OCR index; Postgres french FTS)dossier:read
BlockersControllerapi/blockersGETdossier:read + ownership
PrecedentsControllerapi/precedentsGET, GET outcomes, POST promotedossier:read
AdminControllerapi/adminPOST seed (Dev), GET health—

Auth session + tenancy ​

  1. POST /api/auth/otp creates an AuthSessions row: Id = sess_<guid>, TokenHash = SHA-256(sessionId), role, org, expiry 8 h (72 h with RememberDevice).
  2. InstitutionalAuthMiddleware (all /api/* except login/accept-invite/health) validates Authorization: Bearer or X-Session-Token, loads the Active Membership, populates claims sub, tenant_id, role, session_id.
  3. HttpContextTenantProvider prefers claims over client headers. In Development, Auth:AllowDevHeaders (default true) still allows X-Organization-Id / X-User-* without a bearer for local seed.
  4. InstitutionalAuthorization.RequirePermission / RequireAdmin align with the TS matrix (dossier:read|write|create|archive, tenant:manage, memo:generate, system:manage, …).
  5. TenantRlsMiddleware sets app.current_tenant_id / app.is_platform_operator for FORCE RLS (TenantRlsConnectionInterceptor).

Full flow: Auth & sessions · boundaries: Trust boundaries.

Onboarding / access requests (SoR) ​

State machine persisted in PostgreSQL:

text
SUBMITTED ──(24h magic token)──► EMAIL_VERIFIED ──► COMPLIANCE_REVIEW ──► APPROVED ──► PROVISIONED
                                                         └──────────────► REJECTED
RuleImplementation
Consumer webmail rejectedFreeEmailDomains → CONSUMER_EMAIL_REJECTED
TermstermsAccepted and advisoryDisclaimerAccepted → else TERMS_NOT_ACCEPTED
Rate limitCheckRateLimit(email, max = 5, window = 15 min) → 429 RATE_LIMIT_EXCEEDED
Domain dedupeactive org for domain → 409 TENANT_ALREADY_PROVISIONED; pending request → 409 PENDING_REQUEST_EXISTS
Verify tokenTTL 24 h; only SHA-256(token) stored; e-mail via outbox/SMTP
Verify (anonymous)advances to COMPLIANCE_REVIEW
Review / provisionpermission system:manage (PLATFORM_OPERATOR); provision creates Organization + edition modules + invited INSTITUTION_ADMIN

Operator UI lives in locus-ops /access-requests; public verify page is /request-access/verify on the bank app. See Access requests.

Dossier lifecycle (DealsController) ​

AspectBehaviour
CreatePOST /api/deals — tenantId, lead arranger, currency (XAF/EUR/USD), JurisdictionProfileJson, WorkspacePayloadJson, LeadArrangerId, status
Status enumORIGINATION → IN_DILIGENCE → COMMITTEE_REVIEW → CLOSED / ARCHIVED (DossierStatus)
TransitionsValidated on PUT {id}/status
ArchivePOST {id}/archive requires a rationale ≥ 5 characters; writes workspace.archive payload meta + audit event; hides from lists (includeArchived=false); unarchive reverses
Tenant scopeList / get filtered by OrganizationId; no seed-org fallback across tenants
ReconciliationGET {id}/reconciliation (financial:read) — statements with |Actif − Passif| above tolerance are UNRECONCILED and set covenantCertificationBlocked = true
Cell overridePOST {id}/cells/{cellId}/override (financial:override), body { acceptedValue, reason }; keeps ReportedValue; audit CELL_OVERRIDE — see OCR override SOP
UI create pathPOST /api/locus/deals (BFF) with local fallback if API unavailable
Canonical idsSoR ids are deal_*; aliases (seeg-refinancing-2026, proj_*) resolved BFF-side by canonical-ids.ts

Database models ​

PostgreSQL via EF Core 10 (Data/Entities/, LocusDbContext). 46 entity classes. Table name equals class name unless noted. Source of truth for storage layout; object binaries live in R2 (see Data stores).

Isolation & naming ​

LayerBehaviour
EF HasQueryFilterFail-closed on tenant / dossier-scoped sets when a non-operator tenant is in context
Postgres FORCE RLSlocus_tenant_ok("OrganizationId") or dossier/document join policies (migration PostgresRlsAndTsvectorSearch + findings)
Outside FORCE RLSUsers, AuthSessions, AccessRequests (pre-claim auth; session lookup uses IgnoreQueryFilters)
Platform-globalLegal vault tables — no OrganizationId, no tenant filter; writes require system:manage
Class ≠ tableContradictionRecord → Contradictions; BlockerItem → Blockers
text
Organization (tenant root)
 ├── Membership · AuthSession · EmailOutbox · AuditEvent · AccessRequest?
 ├── Market* · Portfolio* · Precedent · SearchDocument · AlertItem
 └── Dossier
      ├── Document → ExtractedPage · Citation
      ├── FinancialStatement → ExtractedCell
      ├── RatioSnapshot · Contradiction · ProspectusAudit
      ├── StressScenario · QaThread · Blocker
      └── DossierRegulatoryFinding  (vault evidence refs, soft)
Legal vault (platform, no OrganizationId)
 RegulatorySource → LegalSourceArtifact
 LegalInstrument → LegalEdition → LegalProvision → Applicability
                  → LegalAmendment · LegalReviewItem · LegalIngestionLog

Enums (Data/Enums.cs) ​

EnumPurpose
InstitutionEditionLicensed pack: Securities, Credit, AssetManagement, Dfi, Sovereign, Legal, Enterprise
InstitutionalRoleSix core tiers + expanded desk roles (ORIGINATION_BANKER … COMMITTEE_VIEWER)
MembershipStatusActive, Invited, Suspended, Removed
DossierStatusORIGINATION → IN_DILIGENCE → COMMITTEE_REVIEW → CLOSED / ARCHIVED
DocumentClassificationFINANCIAL_STATEMENT, LOAN_AGREEMENT, TERM_SHEET, CONCESSION_AGREEMENT, …
OcrStatusPENDING → PROCESSING → COMPLETED / FAILED
ReconciliationStateRECONCILED, UNRECONCILED, ADJUSTED
ContradictionSeverity / ContradictionStatusCRITICAL/WARNING/INFO · OPEN→ACKNOWLEDGED→RESOLVED/WAIVED
VisaDecisionCOMPLIANT, CONDITIONAL_VISA, FATAL_BLOCKERS
EmailDeliveryStatusPending, Sent, Failed

Identity & tenancy ​

ModelTablePurpose
OrganizationOrganizationsTenant root: corporate domain, Edition, licensed jurisdictions, EnabledModulesJson, ProfileJson (logo/name). FORCE RLS by Id = current tenant GUC.
UserUsersPlatform identity (email, password hash). No OrganizationId; tenancy is via Membership. Outside FORCE RLS.
MembershipMembershipsUser↔org binding: InstitutionalRole, MembershipStatus, invite token, module overrides. Unique (UserId, OrganizationId).
AuthSessionAuthSessionsBearer session: TokenHash = SHA-256(sessionId), role, org, expiry. EF-filtered; outside FORCE RLS for login path.
EmailOutboxEmailOutboxQueued transactional mail (template id + JSON payload, delivery status).

Dossiers & deal room ​

ModelTablePurpose
DossierDossiersMandate / deal workspace: borrower, amounts, currency, DossierStatus, JurisdictionProfileJson, WorkspacePayloadJson, lead arranger.
DocumentDocumentsIngested PDF metadata: S3Key, Sha256Hash, size, page count, OcrStatus, DocumentClassification. Scoped via DossierId.
ExtractedPageExtractedPagesPersisted OCR/native text per page (+ optional word boxes). Unique (DocumentId, PageNumber).
CitationCitationsGrounding snippet: page, bbox ([] when unknown), confidence. Never invent bboxes in UI (R4).
ContradictionRecordContradictionsPairwise cross-doc discrepancy: category, severity, status, dual document/citation refs.
ProspectusAuditProspectusAuditsCOSUMAF pre-visa checklist JSON, score, VisaDecision, attestation hash.
StressScenarioStressScenariosNamed credit-lab stress run: parameters + results JSON.
QaThreadQaThreadsSyndicate VDR Q&A thread (messages JSON, approval state).
BlockerItemBlockersCompleteness / CP blocker (severity, status, optional attached doc).

Financials & analytics ​

ModelTablePurpose
FinancialStatementFinancialStatementsSYSCOHADA statement header for a fiscal year: ActifPassifDelta, ReconciliationState. Unique (DossierId, FiscalYear).
ExtractedCellExtractedCellsLine item: ReportedValue / AcceptedValue, HasHumanOverride, rationale, soft SourceCitationId.
RatioSnapshotRatioSnapshotsDeterministic KPIs (Net Debt, EBE, leverage, DSCR, ICR, covenant flags) for a year/scenario.

Market & portfolio ​

All carry OrganizationId (tenant market book — fail-closed empty when unset). Ingested via ops/SoR market APIs.

ModelTablePurpose
MarketBulletinMarketBulletinsOfficial bulletin payload + content hash (e.g. BEAC/BVMAC).
MarketObservationMarketObservationsTime-series point (SeriesKey, country, value, meta).
AuctionLineAuctionLinesAuction/adjudication result (instrument, yield, tenor).
CurvePublicationCurvePublicationsFitted sovereign curve (e.g. Nelson–Siegel params + RMSE).
PricingScenarioPricingScenariosNamed pricing scenario JSON; optional soft deal link.
BlotterInstrumentBlotterInstrumentsTrading blotter row (ISIN/type + payload).
PortfolioIssuerPortfolioIssuersIssuer master for the book (optional linked deal).
PortfolioInstrumentPortfolioInstrumentsInstrument under an issuer.
PortfolioHoldingPortfolioHoldingsPosition nominal + valuation payload.
PortfolioLimitPortfolioLimitsRisk/limit definition (JSON).
PortfolioExceptionPortfolioExceptionsOpen limit breach / exception case.

Search, alerts & institutional memory ​

ModelTablePurpose
SearchDocumentSearchDocumentsTenant FTS row: French tsvector / GIN (SearchVector), links to dossier/doc/page for copilot & global search.
AlertItemAlertItemsWorkspace surveillance alert (assign / acknowledge / archive).
PrecedentPrecedentsComparable deal memory (country, sector, payload).
PostCloseOutcomePostCloseOutcomesPost-close event linked to a Precedent.

Onboarding & audit ​

ModelTablePurpose
AccessRequestAccessRequestsInstitutional onboarding state machine (SUBMITTED → … → PROVISIONED / REJECTED); optional OrganizationId after provision. Outside FORCE RLS.
AuditEventAuditEventsAppend-only ledger: actor, action, entity, payload, PreviousHash / Hash chain per tenant. SaveChanges blocks update/delete.

No OrganizationId. Shared read for bank resolve/open-statute; writes only through ops (system:manage). See Legal vault & Watchtower.

ModelTablePurpose
RegulatorySourceRegulatorySourcesWatchtower monitor target (URL, fingerprint, schedule metadata).
LegalInstrumentLegalInstrumentsStable statute identity (e.g. AUDCIF), edition-independent.
LegalEditionLegalEditionsDated edition: legal/verification status, optional supersedes link, artifact FK.
LegalSourceArtifactLegalSourceArtifactsStored PDF bytes metadata (Sha256, storage key); discovery source FK.
LegalProvisionLegalProvisionsArticle/provision text + PDF page anchors. Unique (EditionId, Number).
LegalAmendmentLegalAmendmentsSupplement / replace / repeal mapping between editions (does not mutate target text).
LegalProvisionApplicabilityLegalProvisionApplicabilitiesJurisdiction / transaction-type / entity / flag map. CRUD in ops; not yet consumed by analyzeDeal.
LegalReviewItemLegalReviewItemsOps review queue (new edition, HASH_CHANGE, import).
LegalIngestionLogLegalIngestionLogsWatchtower / ingest operational log.

Tenant ↔ vault bridge ​

ModelTablePurpose
DossierRegulatoryFindingDossierRegulatoryFindingsPersisted analyzeDealWithVault findings: finding key unique per dossier, evidence JSON, soft vault edition/provision/artifact ids. Tenant-scoped + FORCE RLS.

Notable columns (deep fields) ​

ModelFields worth knowing
OrganizationEdition, LicensedJurisdictions, EnabledModulesJson, ProfileJson
DocumentS3Key, Sha256Hash (plaintext digest), OcrStatus, Classification
CitationPageNumber, BoundingBox, SnippetText, Confidence
FinancialStatementActifPassifDelta, ReconciliationState
ExtractedCellReportedValue / AcceptedValue, HasHumanOverride, SourceCitationId
ContradictionRecordCategory (e.g. MARGIN_LEAKAGE, TENOR_MISMATCH), CitationAId / CitationBId
DossierRegulatoryFindingFindingKey, VaultEditionId, VaultProvisionId, evidence JSON

Document ingest chain ​

text
DealRoomExplorer
  → POST /api/locus/documents/upload        (BFF · RBAC dealroom:upload · max 40 MB)
      → SHA-256(bytes) on the server
      → AES-256-GCM pack when DOCUMENT_ENCRYPTION_KEY_HEX set
      → R2 PutObject  key = tenants/{tenantId}/dossiers/{dossier}/{doc}/…
      → POST /api/documents                 (SoR: RBAC, tenant S3 prefix guard,
                                             OcrStatus=PENDING, audit DOCUMENT_UPLOAD)
      → extractPagesWithEngine              (native │ printable harvest │ Tesseract worker)
      → POST /api/documents/{id}/ocr-result → COMPLETED | FAILED
  ← meta + download URL (BFF proxy /api/locus/documents/{id}/content)
RuleDetail
SHA-256 dedupeSame Sha256Hash on the same dossier → HTTP 409 DUPLICATE_DOCUMENT
Hash format64-char lowercase hex else 400 INVALID_SHA256
Tenant key guards3Key must start with tenants/{tenantId}/ else 400 INVALID_S3_KEY
UnreadableEmpty pages → 400 OCR_UNREADABLE, OcrStatus=FAILED
PersistReplaces ExtractedPages, Citations, SearchDocuments (ocr_{docId}_p{n}) for the document
Post-OCRPersistFinancialExtractFromDocumentAsync (failure logged, OCR persist still succeeds)
AuditDOCUMENT_UPLOAD, DOCUMENT_OCR_PERSIST
verify-citation ±5 pxPOST /api/documents/{id}/verify-citation — words within bbox ±5 px tolerance; 404 MISSING_DOCUMENT when absent; isValid:false for page out of range, degenerate bbox, or missing OCR page

Details: Dossier → OCR → analysis · Citations (R4).

Analysis job REST contracts ​

Method + pathBehaviour
POST /api/deals/{dealId}/analysis202 Accepted, header Location: /api/analysisjobs/{jobId}, body { jobId, dealId, status, pollUrl, message }
GET /api/analysisjobs/{jobId}{ jobId, status, progressPercent, currentPhase, durationSeconds, result } (+ error); result is the typed AnalysisResult once Completed
GET /api/deals/{dealId}/jobsHistorical runs for the mandate

JobStatus: Queued → Processing → Completed | Failed. Progress milestones in AnalysisOrchestrator: 5 (queued) → 20 (load OCR pages) → 45 (dispatch worker) → 70 (SYSCOHADA extract, ratios, contradictions, blockers, alerts → Postgres) → 100.

Authority model

The POST /api/v1/analyze ping to the worker is best-effort enrichment. The authoritative writer is IDossierAnalyticsService.RunFullDossierAnalysisAsync (engine locus-dossier-analytics 3.0.0), which persists to Postgres. A worker failure does not fail the job. Jobs without TenantId fail with TENANT_REQUIRED.

BFF mirror: POST /api/locus/deals/{dealId}/analysis (dossier:write), GET /api/locus/analysisjobs/{jobId} (dossier:read); legacy POST /api/jobs/analysis / GET /api/jobs/{id} also proxy to the SoR (no in-memory enqueue).

Security hardening (SoR) ​

ControlImplementation
Tenant isolationHasQueryFilter on every OrganizationId entity + dossier-child navigation; null tenant → zero rows
Postgres RLSMigration PostgresRlsAndTsvectorSearch (FORCE RLS, locus_tenant_ok(OrganizationId))
Append-only auditSaveChanges rejects update/delete on AuditEvent
Hash chainSHA256(previousHash|id|orgId|userId|action|entityType|entityId|payload|timestamp); VerifyChainIntegrityAsync
Registration guardsConsumer e-mail rejected; password ≥ 12 chars; domain dedupe (ORG_EXISTS, EMAIL_TAKEN)

Config ​

appsettings.json + locus-backend/.env.example. Nested keys use __ in env (ConnectionStrings__DefaultConnection).

KeyPurpose
ConnectionStrings__DefaultConnectionPostgres
ConnectionStrings__Redis / REDIS_URLAnalysis job store
WorkerSettings__PythonWorkerUrlhttp://<workers-service>:8000
Cors__Origins__0/1bank + ops origins
Seed__RunOnStartupfalse in production
Auth__AllowDevHeadersfalse in production
Queue__AllowInMemory / LOCUS_ALLOW_INMEMORY_QUEUEemergency only
App__PublicWebBaseUrlverify / invite links
Smtp__*outbox delivery

Env-only today (no appsettings key): LEGAL_VAULT_STORAGE_PATH, LEGAL_VAULT_WATCHTOWER_INTERVAL_MINUTES, LEGAL_VAULT_NOTIFY_EMAIL, REDIS_URL, LOCUS_ALLOW_INMEMORY_QUEUE.

Tests & CI ​

dotnet build (.NET 10, Locus.Api.dll) in the monorepo .github/workflows/ci.yml with an appsettings password guard; migration chain verified by scripts/backup-and-migrations-verify.sh. TS-side contract tests: backend_system_of_record.test.ts, analytical_jobs_and_contracts.test.ts, tenant_query_filters.test.ts, audit_hash_chain_sor.test.ts.

Source of trust for Locus. If this conflicts with code, code + IMPLEMENTATION_TRACKER.md win.