Appearance
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 tiersPLATFORM_OPERATOR…SYNDICATE_GUESTplus expanded desk roles inInstitutionalRole), 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
- Require
ConnectionStrings:DefaultConnection(no committed DB password; env / user-secrets only). - Redis for the analysis-job store in Production (
ConnectionStrings__RedisorREDIS_URL) — or emergency in-memory flag (LOCUS_ALLOW_INMEMORY_QUEUE=1).IAnalysisJobStore→RedisAnalysisJobStoreorMemoryAnalysisJobStore. MigrateAsync; seed only ifSeed:RunOnStartup=true(default off in production; Dev-onlyPOST /api/admin/seedfor rare manual loads).- CORS from
Cors:Origins(bank:3000+ ops:3001). - OpenAPI / Scalar in Development.
LegalVaultWatchtowerHostedServiceregistered; 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/.
| Controller | Base route | Endpoints | Permission (typical) |
|---|---|---|---|
AuthController | api/auth | POST register, POST login, POST platform-login, POST otp, POST logout, GET session | public (pre-session) / session |
DealsController | api/deals | GET, GET {id}, POST, PUT {id}/status, POST {id}/archive, POST {id}/unarchive, POST {id}/cells/{cellId}/override, GET {id}/reconciliation | dossier:*, financial:read, financial:override |
DocumentsController | api/documents | GET, POST, GET {id}, GET {id}/pages, POST {id}/verify-citation, POST {id}/ocr-result | dealroom:upload, dealroom:read_approved |
AnalysisJobsController | api/analysisjobs + ~/api/deals/… | POST /api/deals/{dealId}/analysis, GET /api/analysisjobs/{jobId}, GET /api/deals/{dealId}/jobs | dossier:write / dossier:read + deal ownership |
RegulatoryFindingsController | api/deals/{dealId}/regulatory-findings | GET, PUT (replace-all) | regulatory:read / dossier:write |
LegalVaultController | api/legal-vault | GET resolve, GET artifacts/{id}, GET artifacts/{id}/content | regulatory:read (read-only, no tenant writes) |
RegulatoryAdminController | api/admin/regulatory | overview, sources, instruments, amendments, applicability, editions, artifacts, review, logs (see Legal vault) | system:manage (PLATFORM_OPERATOR) |
MarketController | api/market | POST ingest, GET overview | financial:write/tenant:manage; financial:read |
MembersController | api/members | GET, GET roles, GET audit, GET {id}, POST invite, POST {id}/resend, POST accept-invite, PATCH {id}, DELETE {id} | tenant:manage |
OrganizationsController | api/organizations | GET current, PUT current/profile, PUT current/edition, PUT current/entitlements | tenant:manage; regulatory module cannot be disabled |
AccessRequestsController | api/access-requests | GET, GET {id}, POST, GET/POST verify, POST {id}/review, POST {id}/provision | public submit/verify; system:manage review/provision |
ExportsController | api/exports | POST memo, syscohada, ratios, ratios/summary, contradictions, risks, cosumaf-attestation | per export |
AlertsController | api/alerts | GET, POST {id}/assign, {id}/acknowledge, {id}/archive | financial:read |
PortfolioController | api/portfolio | GET book; CRUD holdings, instruments, issuers | financial:read / write |
QaController | api/deals/{dealId}/qa | GET, POST, PATCH {threadId} | qa:* |
SearchController | api/search | GET (ILIKE + OCR index; Postgres french FTS) | dossier:read |
BlockersController | api/blockers | GET | dossier:read + ownership |
PrecedentsController | api/precedents | GET, GET outcomes, POST promote | dossier:read |
AdminController | api/admin | POST seed (Dev), GET health | — |
Auth session + tenancy
POST /api/auth/otpcreates anAuthSessionsrow:Id = sess_<guid>,TokenHash = SHA-256(sessionId), role, org, expiry 8 h (72 h withRememberDevice).InstitutionalAuthMiddleware(all/api/*except login/accept-invite/health) validatesAuthorization: BearerorX-Session-Token, loads the ActiveMembership, populates claimssub,tenant_id,role,session_id.HttpContextTenantProviderprefers claims over client headers. In Development,Auth:AllowDevHeaders(defaulttrue) still allowsX-Organization-Id/X-User-*without a bearer for local seed.InstitutionalAuthorization.RequirePermission/RequireAdminalign with the TS matrix (dossier:read|write|create|archive,tenant:manage,memo:generate,system:manage, …).TenantRlsMiddlewaresetsapp.current_tenant_id/app.is_platform_operatorfor 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| Rule | Implementation |
|---|---|
| Consumer webmail rejected | FreeEmailDomains → CONSUMER_EMAIL_REJECTED |
| Terms | termsAccepted and advisoryDisclaimerAccepted → else TERMS_NOT_ACCEPTED |
| Rate limit | CheckRateLimit(email, max = 5, window = 15 min) → 429 RATE_LIMIT_EXCEEDED |
| Domain dedupe | active org for domain → 409 TENANT_ALREADY_PROVISIONED; pending request → 409 PENDING_REQUEST_EXISTS |
| Verify token | TTL 24 h; only SHA-256(token) stored; e-mail via outbox/SMTP |
| Verify (anonymous) | advances to COMPLIANCE_REVIEW |
| Review / provision | permission 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)
| Aspect | Behaviour |
|---|---|
| Create | POST /api/deals — tenantId, lead arranger, currency (XAF/EUR/USD), JurisdictionProfileJson, WorkspacePayloadJson, LeadArrangerId, status |
| Status enum | ORIGINATION → IN_DILIGENCE → COMMITTEE_REVIEW → CLOSED / ARCHIVED (DossierStatus) |
| Transitions | Validated on PUT {id}/status |
| Archive | POST {id}/archive requires a rationale ≥ 5 characters; writes workspace.archive payload meta + audit event; hides from lists (includeArchived=false); unarchive reverses |
| Tenant scope | List / get filtered by OrganizationId; no seed-org fallback across tenants |
| Reconciliation | GET {id}/reconciliation (financial:read) — statements with |Actif − Passif| above tolerance are UNRECONCILED and set covenantCertificationBlocked = true |
| Cell override | POST {id}/cells/{cellId}/override (financial:override), body { acceptedValue, reason }; keeps ReportedValue; audit CELL_OVERRIDE — see OCR override SOP |
| UI create path | POST /api/locus/deals (BFF) with local fallback if API unavailable |
| Canonical ids | SoR 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
| Layer | Behaviour |
|---|---|
EF HasQueryFilter | Fail-closed on tenant / dossier-scoped sets when a non-operator tenant is in context |
| Postgres FORCE RLS | locus_tenant_ok("OrganizationId") or dossier/document join policies (migration PostgresRlsAndTsvectorSearch + findings) |
| Outside FORCE RLS | Users, AuthSessions, AccessRequests (pre-claim auth; session lookup uses IgnoreQueryFilters) |
| Platform-global | Legal vault tables — no OrganizationId, no tenant filter; writes require system:manage |
| Class ≠ table | ContradictionRecord → 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 · LegalIngestionLogEnums (Data/Enums.cs)
| Enum | Purpose |
|---|---|
InstitutionEdition | Licensed pack: Securities, Credit, AssetManagement, Dfi, Sovereign, Legal, Enterprise |
InstitutionalRole | Six core tiers + expanded desk roles (ORIGINATION_BANKER … COMMITTEE_VIEWER) |
MembershipStatus | Active, Invited, Suspended, Removed |
DossierStatus | ORIGINATION → IN_DILIGENCE → COMMITTEE_REVIEW → CLOSED / ARCHIVED |
DocumentClassification | FINANCIAL_STATEMENT, LOAN_AGREEMENT, TERM_SHEET, CONCESSION_AGREEMENT, … |
OcrStatus | PENDING → PROCESSING → COMPLETED / FAILED |
ReconciliationState | RECONCILED, UNRECONCILED, ADJUSTED |
ContradictionSeverity / ContradictionStatus | CRITICAL/WARNING/INFO · OPEN→ACKNOWLEDGED→RESOLVED/WAIVED |
VisaDecision | COMPLIANT, CONDITIONAL_VISA, FATAL_BLOCKERS |
EmailDeliveryStatus | Pending, Sent, Failed |
Identity & tenancy
| Model | Table | Purpose |
|---|---|---|
Organization | Organizations | Tenant root: corporate domain, Edition, licensed jurisdictions, EnabledModulesJson, ProfileJson (logo/name). FORCE RLS by Id = current tenant GUC. |
User | Users | Platform identity (email, password hash). No OrganizationId; tenancy is via Membership. Outside FORCE RLS. |
Membership | Memberships | User↔org binding: InstitutionalRole, MembershipStatus, invite token, module overrides. Unique (UserId, OrganizationId). |
AuthSession | AuthSessions | Bearer session: TokenHash = SHA-256(sessionId), role, org, expiry. EF-filtered; outside FORCE RLS for login path. |
EmailOutbox | EmailOutbox | Queued transactional mail (template id + JSON payload, delivery status). |
Dossiers & deal room
| Model | Table | Purpose |
|---|---|---|
Dossier | Dossiers | Mandate / deal workspace: borrower, amounts, currency, DossierStatus, JurisdictionProfileJson, WorkspacePayloadJson, lead arranger. |
Document | Documents | Ingested PDF metadata: S3Key, Sha256Hash, size, page count, OcrStatus, DocumentClassification. Scoped via DossierId. |
ExtractedPage | ExtractedPages | Persisted OCR/native text per page (+ optional word boxes). Unique (DocumentId, PageNumber). |
Citation | Citations | Grounding snippet: page, bbox ([] when unknown), confidence. Never invent bboxes in UI (R4). |
ContradictionRecord | Contradictions | Pairwise cross-doc discrepancy: category, severity, status, dual document/citation refs. |
ProspectusAudit | ProspectusAudits | COSUMAF pre-visa checklist JSON, score, VisaDecision, attestation hash. |
StressScenario | StressScenarios | Named credit-lab stress run: parameters + results JSON. |
QaThread | QaThreads | Syndicate VDR Q&A thread (messages JSON, approval state). |
BlockerItem | Blockers | Completeness / CP blocker (severity, status, optional attached doc). |
Financials & analytics
| Model | Table | Purpose |
|---|---|---|
FinancialStatement | FinancialStatements | SYSCOHADA statement header for a fiscal year: ActifPassifDelta, ReconciliationState. Unique (DossierId, FiscalYear). |
ExtractedCell | ExtractedCells | Line item: ReportedValue / AcceptedValue, HasHumanOverride, rationale, soft SourceCitationId. |
RatioSnapshot | RatioSnapshots | Deterministic 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.
| Model | Table | Purpose |
|---|---|---|
MarketBulletin | MarketBulletins | Official bulletin payload + content hash (e.g. BEAC/BVMAC). |
MarketObservation | MarketObservations | Time-series point (SeriesKey, country, value, meta). |
AuctionLine | AuctionLines | Auction/adjudication result (instrument, yield, tenor). |
CurvePublication | CurvePublications | Fitted sovereign curve (e.g. Nelson–Siegel params + RMSE). |
PricingScenario | PricingScenarios | Named pricing scenario JSON; optional soft deal link. |
BlotterInstrument | BlotterInstruments | Trading blotter row (ISIN/type + payload). |
PortfolioIssuer | PortfolioIssuers | Issuer master for the book (optional linked deal). |
PortfolioInstrument | PortfolioInstruments | Instrument under an issuer. |
PortfolioHolding | PortfolioHoldings | Position nominal + valuation payload. |
PortfolioLimit | PortfolioLimits | Risk/limit definition (JSON). |
PortfolioException | PortfolioExceptions | Open limit breach / exception case. |
Search, alerts & institutional memory
| Model | Table | Purpose |
|---|---|---|
SearchDocument | SearchDocuments | Tenant FTS row: French tsvector / GIN (SearchVector), links to dossier/doc/page for copilot & global search. |
AlertItem | AlertItems | Workspace surveillance alert (assign / acknowledge / archive). |
Precedent | Precedents | Comparable deal memory (country, sector, payload). |
PostCloseOutcome | PostCloseOutcomes | Post-close event linked to a Precedent. |
Onboarding & audit
| Model | Table | Purpose |
|---|---|---|
AccessRequest | AccessRequests | Institutional onboarding state machine (SUBMITTED → … → PROVISIONED / REJECTED); optional OrganizationId after provision. Outside FORCE RLS. |
AuditEvent | AuditEvents | Append-only ledger: actor, action, entity, payload, PreviousHash / Hash chain per tenant. SaveChanges blocks update/delete. |
Legal vault (platform-global)
No OrganizationId. Shared read for bank resolve/open-statute; writes only through ops (system:manage). See Legal vault & Watchtower.
| Model | Table | Purpose |
|---|---|---|
RegulatorySource | RegulatorySources | Watchtower monitor target (URL, fingerprint, schedule metadata). |
LegalInstrument | LegalInstruments | Stable statute identity (e.g. AUDCIF), edition-independent. |
LegalEdition | LegalEditions | Dated edition: legal/verification status, optional supersedes link, artifact FK. |
LegalSourceArtifact | LegalSourceArtifacts | Stored PDF bytes metadata (Sha256, storage key); discovery source FK. |
LegalProvision | LegalProvisions | Article/provision text + PDF page anchors. Unique (EditionId, Number). |
LegalAmendment | LegalAmendments | Supplement / replace / repeal mapping between editions (does not mutate target text). |
LegalProvisionApplicability | LegalProvisionApplicabilities | Jurisdiction / transaction-type / entity / flag map. CRUD in ops; not yet consumed by analyzeDeal. |
LegalReviewItem | LegalReviewItems | Ops review queue (new edition, HASH_CHANGE, import). |
LegalIngestionLog | LegalIngestionLogs | Watchtower / ingest operational log. |
Tenant ↔ vault bridge
| Model | Table | Purpose |
|---|---|---|
DossierRegulatoryFinding | DossierRegulatoryFindings | Persisted analyzeDealWithVault findings: finding key unique per dossier, evidence JSON, soft vault edition/provision/artifact ids. Tenant-scoped + FORCE RLS. |
Notable columns (deep fields)
| Model | Fields worth knowing |
|---|---|
Organization | Edition, LicensedJurisdictions, EnabledModulesJson, ProfileJson |
Document | S3Key, Sha256Hash (plaintext digest), OcrStatus, Classification |
Citation | PageNumber, BoundingBox, SnippetText, Confidence |
FinancialStatement | ActifPassifDelta, ReconciliationState |
ExtractedCell | ReportedValue / AcceptedValue, HasHumanOverride, SourceCitationId |
ContradictionRecord | Category (e.g. MARGIN_LEAKAGE, TENOR_MISMATCH), CitationAId / CitationBId |
DossierRegulatoryFinding | FindingKey, 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)| Rule | Detail |
|---|---|
| SHA-256 dedupe | Same Sha256Hash on the same dossier → HTTP 409 DUPLICATE_DOCUMENT |
| Hash format | 64-char lowercase hex else 400 INVALID_SHA256 |
| Tenant key guard | s3Key must start with tenants/{tenantId}/ else 400 INVALID_S3_KEY |
| Unreadable | Empty pages → 400 OCR_UNREADABLE, OcrStatus=FAILED |
| Persist | Replaces ExtractedPages, Citations, SearchDocuments (ocr_{docId}_p{n}) for the document |
| Post-OCR | PersistFinancialExtractFromDocumentAsync (failure logged, OCR persist still succeeds) |
| Audit | DOCUMENT_UPLOAD, DOCUMENT_OCR_PERSIST |
| verify-citation ±5 px | POST /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 + path | Behaviour |
|---|---|
POST /api/deals/{dealId}/analysis | 202 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}/jobs | Historical 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)
| Control | Implementation |
|---|---|
| Tenant isolation | HasQueryFilter on every OrganizationId entity + dossier-child navigation; null tenant → zero rows |
| Postgres RLS | Migration PostgresRlsAndTsvectorSearch (FORCE RLS, locus_tenant_ok(OrganizationId)) |
| Append-only audit | SaveChanges rejects update/delete on AuditEvent |
| Hash chain | SHA256(previousHash|id|orgId|userId|action|entityType|entityId|payload|timestamp); VerifyChainIntegrityAsync |
| Registration guards | Consumer 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).
| Key | Purpose |
|---|---|
ConnectionStrings__DefaultConnection | Postgres |
ConnectionStrings__Redis / REDIS_URL | Analysis job store |
WorkerSettings__PythonWorkerUrl | http://<workers-service>:8000 |
Cors__Origins__0/1 | bank + ops origins |
Seed__RunOnStartup | false in production |
Auth__AllowDevHeaders | false in production |
Queue__AllowInMemory / LOCUS_ALLOW_INMEMORY_QUEUE | emergency only |
App__PublicWebBaseUrl | verify / 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.