Artha Fintech Operations — Engineering Onboarding

2026-09-17 · @Someone

Everything a new engineer needs before their first pull request: what the system does, how it is put together, what it is built with, and how we use Claude Code to add to it.

1. What the platform does

Artha Fintech Operations is the platform Artha Fintech runs its business on. Everything the company does sits in one of two halves: work done for customers, and work done inside the company.

Naming note. The code is still called OpsLayer — the solution is OpsLayer.sln, the projects are OpsLayer.Api, OpsLayer.Domain and so on, and the database is opslayer. "Artha Fintech Operations" is the product; "OpsLayer" is the codebase. Don't rename anything in the repository.

Each app owns a database schema and is referred to internally by a module code. There is no M2 — don't go looking for it.

Part 1 — Customer Apps

These deal with Artha Fintech's customers: who they are, what they bought, what we run for them, and what is going wrong.

App Code Schema What it does
CRM M5 m5 The customer record itself. Pipeline → onboarding → contracts → billing. Holds the catalog of what Artha sells and the connection registry that tells every other app where a customer's Azure subscriptions, Entra tenants and chat channels live.
Cloud Ops M6 m6 The customer's Azure estate: subscriptions, cost and budget, secure score, Defender alerts, app health, and findings that need fixing. Includes cost recharge back to the customer.
Support M7 m7 The customer's tickets, arriving from Teams, Telegram, WhatsApp, Slack, Zendesk or the portal, all on one SLA clock taken from their contract. Two tiers: L1 where Artha supports the customer, L2 where Artha is second line for the customer's own end customers.
Delivery M1 m1 The customer's software projects in Azure DevOps: work items, sprints, capacity, pull requests. A project may instead be internal — customer_id is optional.

How they join up. A customer in CRM is the anchor. Cloud Ops and Support both route by the CRM connection registry, Delivery links by customer_id, and all four surface on Customer 360 — one page showing that customer's contract, money owed, cloud health, open tickets and delivery state together. Any new customer-facing feature should carry a customer_id so it appears there too.

Part 2 — Internal Apps

These deal with Artha Fintech itself: its people, its money, and the platform the other apps stand on.

App Code Schema What it does
Foundation M0 m0 Not a screen so much as the floor everything stands on: identity and sign-in, sessions, step-up auth, the directory of people and teams, companies, the attention hub, KPIs, notifications, external connectors, and the audit trail. Every other app depends on it.
HR M3 m3 Employees, attendance, leave, timesheets, expenses and payroll. Employee records are created automatically from Microsoft 365.
Accounting M4 m4 Artha's own books: ledger and journals, sales and purchases, bank reconciliation, fixed assets and reports. Customer invoices originate here, raised automatically from CRM contracts.

The two ideas that hold it together

The attention hub (M0). Every app pushes whatever needs a human into one scored, assigned, escalating feed. The newcomer's instinct is to build a bespoke alert list for their own app. Don't. Raise an attention item and it lands in the same place as everything else, with the same ownership, escalation and audit.

The customer hub (M0 + M5). m0.customers plus the M5 customer_connection registry is how Cloud Ops and Support find a customer's real systems. A connection with no connector_instance_id is in local mode — derived or seeded data, no live credential.

Who uses it

Artha staff, imported automatically from Microsoft 365 — the majority. Customer users, who see only their own organisation's data, across a three-tier hierarchy: Artha sees everything, a customer sees itself and its own end customers, an end customer sees only itself.

2. Architecture

A .NET 8 API and a React SPA over one PostgreSQL database. Dependencies run one way only:

flowchart LR
  SK[SharedKernel] --> D[Domain] --> A[Application] --> I[Infrastructure] --> API[Api]

Each project has per-module folders. Application holds requests, handlers and pure rules and can never touch a database directly. Infrastructure holds EF configuration, workers and external clients. Unit tests reference Application only, which is what keeps the rules testable without a database.

The request pipeline, in order

This is the single most useful thing to understand. Every request goes through it, and most "why is this 403 / 404 / 428" questions are answered by knowing which stage fired.

flowchart TD
  H[HTTP: CORS, auth] --> PM[PrincipalMiddleware] --> RL[Rate limiter] --> C[Controller]
  C --> SA[SecurityAudit] --> V[Validation] --> AU[Authorization] --> SU[StepUp] --> TX[Transaction] --> HA[Handler]

The rate limiter sits after PrincipalMiddleware on purpose: it partitions on the ops_uid claim the middleware stamps. PrincipalMiddleware also requires a live server session from X-Session-Id on every path except session start.

Then five MediatR behaviours, outermost first:

Behaviour What it enforces What you do as a handler author
SecurityAuditBehavior Catches ForbiddenException anywhere inside and records m0.security.denied in its own scope, then rethrows. Throw ForbiddenException for refusals so they reach the audit trail.
ValidationBehavior Runs FluentValidation validators. There are no validator classes in the repo today, so this stage is currently inert. Follow the existing pattern instead: validate in a static rule class and throw ValidationFailedException.
AuthorizationBehavior Fails closed. No [OpsAuthorize] means it throws. A denied IQuery is 404; a denied command is 403. Put [OpsAuthorize("mX.perm")] on the record, implement IScopedRequest, mark reads IQuery.
StepUpBehavior Sensitive actions need a passkey. Without a valid X-Step-Up-Token it returns 428 STEP_UP_REQUIRED. A token covers one call to one action. Add [RequiresStepUp]. The SPA handles the retry.
TransactionBehavior Calls SaveChangesAsync for anything not marked IQuery. Never call SaveChangesAsync yourself in a command handler.

One DbContext, per-module interfaces

OpsDbContext is split into partials (OpsDbContext.M5.cs and so on). Handlers depend on a module interface, and the interfaces inherit: IOpsDbIHrDb/IAccDbICrmDbICloudDb/IDeliveryDbISupportDb. So a support handler can read CRM tables, but a CRM handler cannot read support tables. Names are snake_case; the migrations history table is m0.__ef_migrations.

How modules talk to each other

3. The security model

Read this section twice. It is the part of the codebase where a well-meant change does real damage, and a hardening pass (documented in docs/m0-hardening.md) deliberately removed every second route to access. Don't put one back.

EffectiveScope.Allows is the single enforcement point. One method, in SharedKernel/Principal.cs, answers every access question:

Allows(permission, companyId?, teamId?, customerTenant?)

Four rules follow from it, and all four are load-bearing:

  1. Never branch on a role name. No Roles.Contains("Admin") anywhere. Permission keys only. A source scan fails the build if you try.
  2. IsPlatformAdmin is the only bypass — group scope plus m0.platform.admin. Never add a second shortcut.
  3. Platform-only keys (*, m0.platform.admin, m0.tenant.manage, m0.grant.write) are never carried by a company-scoped grant.
  4. Out of scope reads like non-existent. A denied query returns 404 or an empty page, never an error that confirms the record exists.

Who gets access, and how

Route to access How it is granted Scope it gives
Role grant An administrator, in Directory → person → Roles & scope The role's keys, at group, company, team or country scope
M365 Application Administrator Automatic. Read from the wids claim at every sign-in, and removed the moment M365 takes the role away Group Admin — full access
M365 administrative unit An administrator ticks screens for a unit in Admin → Units & access; the screens' permission keys are folded into the person's scope The ticked screens' keys. Platform-only keys are refused from this source
Account ownership Automatic. Being set as a customer's account owner Read-only (m5.customer.read, m6.cloud.read, m7.ticket.read), limited to the customers they own

That last row is the only customer-level boundary in the system, added in September 2026. It is enforced in exactly one place — CrmAccess.VisibleAsync — which every M5, M6 and M7 read funnels through. Everything else scopes by company.

Rules in one place

Application/M0/Security.cs holds the security rules as pure functions: GrantRules, ModuleOwnership, AttentionAccess, AccountOwnerAccess, StepUpLockout, SecurityConfigRules, NetworkRules, SecretScrubber. Extend these rather than writing checks inline — that is what makes them unit-testable and reviewable.

Three more things that bite

4. Technologies used

Versions below are the pinned ones. Every package is pinned to an exact version — a source scan fails the build on a floating range, so don't add ^ or *.

Backend

What Version Why it is here
.NET / C# net8.0, C# 12 Nullable enabled, implicit usings, file-scoped namespaces
ASP.NET Core 8.0.11 Controllers, SignalR hub at /hubs/ops, health checks
MediatR 12.4.1 The request pipeline — every behaviour in section 2
EF Core + Npgsql 8.0.11 ORM over PostgreSQL 16
EFCore.NamingConventions 8.0.3 snake_case. It also silently renames your indexes — see section 7
FluentValidation 11.11.0 Wired into the pipeline but no validators exist yet
Serilog.AspNetCore 8.0.3 Structured request logging
Fido2 / Otp.NET / QRCoder 3.0.1 / 1.4.0 / 1.6.0 Step-up: passkeys and TOTP
Azure.Identity / KeyVault.Secrets 1.13.1 / 4.7.0 ISecretStore in deployment; AES-GCM locally
Azure.Messaging.ServiceBus 7.18.2 Outbox transport
MailKit 4.16.0 SMTP notifications
NCrontab / NCalcSync 3.3.3 / 5.2.0 Worker schedules; the rules engine
Swashbuckle 7.2.0 Swagger, in Development or behind Api:Swagger=true

Frontend

What Version Why it is here
React + TypeScript 18.3.1 / 5.7.2 npm run typecheck is the only web gate — there is no linter and no web tests
Vite 6.4.3 Dev server must be port 5173: it is the Entra redirect URI and a CORS/WebAuthn origin
Fluent UI v9 9.56.8 All UI. Theme tokens in src/ui/theme.ts, shared components in src/ui/components.tsx
Redux Toolkit + RTK Query 2.5.0 One API slice per module, injected into src/app/api.ts; cross-module invalidation by shared tag names
MSAL browser / react 3.28.1 / 2.2.0 Microsoft Entra sign-in
SignalR client 8.0.7 Live updates
SimpleWebAuthn browser 10.0.0 Passkey step-up
react-router-dom 6.30.6 Whole route table lives in src/main.tsx

Infrastructure

PostgreSQL 16, Docker (one multi-stage image: node build → dotnet publish → runtime serving the SPA from wwwroot), Railway for deployment, Microsoft Entra ID for sign-in and Microsoft Graph for the directory sync. Tests are xUnit with FluentAssertions.

Two dependency advisories are knowingly open (NCalcSync, react-router v6). Both need major upgrades. Read docs/m0-hardening.md A06 before "fixing" them.

5. Running it, and the gates

Secrets are not in the repo. Before anything works, set them once per machine:

cd src/backend/OpsLayer.Api
dotnet user-secrets set "ConnectionStrings:Postgres" "Host=…;Database=opslayer;Username=…;Password=…"
dotnet user-secrets set "Secrets:LocalKey" "<32 random bytes, base64>"

Then pick one:

docker compose up --build          # everything: web :8080, API :5080, Postgres :5432

cd src/backend && dotnet run --project OpsLayer.Api --urls http://localhost:5080   # API from source
cd src/web && npm ci && npm run dev                                                # SPA, port 5173 only

The API auto-migrates and seeds reference data at every start. Startup refuses unsafe configuration — if it exits with Unsafe configuration: …, that is SecurityConfigRules doing its job, not a bug. A wildcard CORS origin aborts in every environment.

Tests

dotnet build src/backend/OpsLayer.sln -c Release     # what CI runs
dotnet test  src/backend/OpsLayer.sln
npm run typecheck                                    # the only web gate

Five source scans that fail the build

These scan the repository itself, in tests/OpsLayer.UnitTests/SecurityTests.cs. New code must satisfy all five:

  1. No SQL built from strings. No FromSqlRaw/ExecuteSqlRaw, no NpgsqlCommand from an interpolated string. Use interpolated FromSql, or a const statement with bound parameters.
  2. Authorization never checks a role name. Permission keys only.
  3. Every company-targeted M0 request is re-checked in its handler, with ScopeHelper.EnsureCompanyAccess(p, r) as the literal first statement and the parameter named exactly.
  4. Committed configuration holds no secrets. Any credential-shaped key, or anything under ConnectionStrings:, must be empty.
  5. Every package is pinned to an exact version.

Tests are sentences

Name them as claims, not labels: Heartbeat_extends_idle_never_absolute_and_the_absolute_limit_always_wins. Use a FluentAssertions because string whenever the assertion encodes a business rule. Security tests carry the control id (A01_…).

6. Building a feature with Claude

Claude Code has written most of this codebase, and it works well because the repository is set up to make it work. Understanding that setup is the difference between getting a good feature and getting a plausible-looking mess.

Why it works here

CLAUDE.md at the repository root is loaded into every session automatically. It carries the architecture, the coding standards, the pipeline table, the feature recipe and the gotchas. You do not need to re-explain the codebase in your prompt. If you learn something a future session would need, add it to CLAUDE.md — that file is infrastructure, not documentation.

The five source scans matter more than they look. They are guard rails Claude cannot talk its way past: if it writes raw SQL or branches on a role name, the build fails. Machine-checked conventions beat written ones.

The workflow

flowchart TD
  A[Describe the outcome] --> B[Ask for a plan first]
  B --> C[Read and correct the plan]
  C --> D[Approve, then it builds]
  D --> E[Build + tests + typecheck]
  E --> F[Review the diff yourself]

1. Describe the outcome, not the implementation. "Account owners should see only their own customers' data" beats "add a CustomerScope property to EffectiveScope". It knows the codebase; it does not know your intent.

2. Always ask for a plan first on anything non-trivial. The phrase that works: "properly plan this and show me — after my approval you can start building." The plan is where mistakes are cheap. A real plan for this codebase should name the files it will touch, the migration it will generate, and which existing rule it has to respect.

3. Correct the plan. This is the highest-leverage minute you will spend. A plan that says "hide the menu item" when routes are unguarded is wrong, and catching that in the plan saves a rewrite.

4. Make it follow the seven-step recipe. Every feature in this codebase goes the same way, and the recipe is in CLAUDE.md:

  1. Domain — entity and state machine in OpsLayer.Domain/Mx/. Raise outbox events for anything another module cares about.
  2. Application — request record and handler together in one file. [OpsAuthorize("mx.perm")], IQuery for reads, IScopedRequest plus EnsureCompanyAccess when it targets a company, [RequiresStepUp] for anything sensitive. Pure logic goes in a static rule class.
  3. Infrastructure — EF configuration in the module's OpsDbContext.Mx.cs partial; workers and external clients in Infrastructure/Mx/.
  4. Migrationdotnet ef migrations add <Name> -p OpsLayer.Infrastructure -s OpsLayer.Api. Generated from the model, never hand-written.
  5. Api — a one-line controller action. New permission keys go in Api/Seed/Seeder*.cs.
  6. Web — endpoint via injectEndpoints, screen in src/features/<module>/, route in src/main.tsx.
  7. Tests — unit tests for the rule class, an integration test for the endpoint's access rules.

5. Demand evidence, not assurance. Ask it to run dotnet build -c Release, dotnet test and npm run typecheck, and to show you the output. "It should work" is not a result. If it says a test passes, the count should be in the transcript.

6. Review the diff yourself. You own the code. Claude is fast and careful but it is not accountable — you are.

What to say when it goes wrong

Three things to be careful about

7. The delivery process, step by step

Every feature goes through the same eleven steps. Nothing here is optional — the steps people are tempted to skip (7 and 8) are the ones that cost the team later.

flowchart LR
  A[Understand<br/>steps 1-2] --> B[Prompt<br/>steps 3-5]
  B --> C[Build + test<br/>steps 6-7]
  C --> D[Document<br/>steps 8-9]
  D --> E[Review + release<br/>steps 10-11]
# Step What good looks like
1 Understand the requirement You can state the outcome in one sentence and name who asked for it. If two readings are possible, go back and ask. An ambiguity resolved now costs one message; resolved after building costs a rewrite.
2 Analyse Find where it belongs: which app, which module, which existing screen and handler. Check whether something close already exists — it often does. Read the relevant part of DEV_NOTES.md and any rule class that already covers part of it.
3 Prepare the prompt Describe the outcome and the constraints, not the implementation. Name the module. Include your acceptance criteria. End with: "plan this properly and show me — after my approval you can start building."
4 Lead reviews the prompt The lead is checking that you understood the requirement and that the approach fits the architecture. This is a two-minute review that prevents a two-day rewrite.
5 Correct Apply the suggestions to the prompt, not to the code. If the lead's comment changes what is being built, go back to step 1 and restate the outcome.
6 Build + unit tests Claude produces a plan first — read it and correct it before approving, the same way your lead read your prompt. Then it builds. Unit tests for every pure rule class; an integration test for any endpoint that enforces access. Finish with the three gates green: dotnet build -c Release, dotnet test, npm run typecheck.
7 Test against your requirement Re-read step 1 and verify each point yourself in the running app. "It builds" is not a test. If there is a migration, apply it to a fresh database and confirm the app starts.
8 Update CLAUDE.md Add what a future session could not work out from the code: a new rule, a new convention, a trap you hit. CLAUDE.md is infrastructure — it is why Claude works well in this repository.
9 Upload your docs and prompts See section 8 for where and what.
10 Lead reviews the build The diff, the migration, and anything touching security. You are accountable for this code, not Claude.
11 Release to live Deploy, then smoke-test the feature on the live site before you call it done. Know how to roll back before you press the button.

Where people go wrong

Step 7 gets skipped. The most common failure is treating a green build as a passed test. The build proves the code compiles and the rules hold; it says nothing about whether you built what was asked for.

Step 8 gets skipped. It feels like paperwork. It isn't — it is the difference between the next person's session starting informed or starting blind.

Step 6 gets started before step 4. Building before the prompt is reviewed usually means rebuilding.

If the change touches security

Anything that touches EffectiveScope, ScopeResolver, a MediatR behaviour, CrmAccess.VisibleAsync, a permission key or a grant needs a higher bar at steps 6, 7 and 10:

8. Where documents and prompts live

Everything you write on the way to a feature goes in one place:

Operations > Shared > Functional Documents > [your folder name] > upload documents

One folder per person. Upload at step 9, once the feature is built and tested — not before, so what you store is what actually shipped rather than what you first intended.

What to upload

Item Why it matters
The requirement you worked from The reviewer, and anyone picking it up later, can check what was actually asked for
The prompt you used, as sent The most reusable artefact you will produce. A prompt that worked is a template for the next person
The plan Claude produced, and your corrections to it Shows the reasoning behind the design, including options that were rejected
Any decision taken along the way Especially assumptions, and anything you deliberately left out of scope

Naming

Use something a stranger can search: the feature name and the date, for example account-owner-access-2026-09-17. Keep the prompt and its plan side by side under that name so the pair stays together.

Why this is worth doing

Prompts are the reusable part of this way of working. A good prompt for "add a screen with unit-based access" saves the next person an hour and, more importantly, produces a result that matches the house style rather than reinventing it. The folder is the team's library of what has already been figured out.

Two things it is not for: anything containing a secret, a connection string or a credential — those never leave user-secrets and the environment; and the code itself, which belongs in the repository with the pull request.

9. A worked example, and the traps

Worked example: account-owner access

A real feature, built in September 2026. The ask was one sentence: "when a user logs in, check whether any customers are assigned to him, then show my customers and give view access to that customer's data."

What made it work was the planning conversation, not the code:

  1. The investigation came first. Before writing anything, the existing state was traced: does customer-level access already exist? Answer — ScopeKind.Customer existed in the model but no request implemented TargetCustomerTenant, so it was dead code. That one finding changed the whole design.
  2. An ambiguity was resolved out loud. "All customer data" could mean all customers or all data of their customers. Guessing would have built the wrong feature or a security hole. Asking cost one message.
  3. The design followed the architecture. One choke point — CrmAccess.VisibleAsync — because M5, M6 and M7 all read through it. One enforcement point, not fifteen sprinkled checks.
  4. The rule went in a pure class (AccountOwnerAccess) with five unit tests pinning it: read-only keys, never platform keys, and never narrowing someone who already had wider access from a role.
  5. The gap was stated, not hidden. The integration suite was blocked by an unrelated migration, so the boundary has no end-to-end test. That was reported rather than glossed over.

That last point is the habit worth copying. An unverified claim is worse than a known gap.

Traps that catch everyone

Trap What happens What to do
Two migration folders Persistence/Migrations/ holds InitialM0 and the model snapshot; everything since is in Infrastructure/Migrations/. M4Accounting also creates m5/m1/m6/m7 tables because it was regenerated A new migration must not recreate them. Read the generated file.
Naming convention renames your indexes You name an index; EF Core silently renames it to …_id1 Use .HasDatabaseName(…) explicitly
Forgetting IQuery A read pays a SaveChanges, and a denied read returns 403 instead of 404, leaking existence Mark every read IQuery
Missing [OpsAuthorize] Throws at runtime, not compile time — you find out on the first request Add it with the request record, not later
Workers wait 30 seconds before the first tick Your worker "doesn't run" In tests, tick it by reflection on the protected RunAsync
Seeding runs at every start A duplicate key in a seed list crashes first boot Dedup against the database, following the existing pattern
SqlCapture recordings overlap Test collections run in parallel and pollute each other Always filter captured SQL by something the test owns
CI runs only on pushes to main The default branch is master, so pushes run nothing Run the build locally before you push
Document What it is
CLAUDE.md The map. Read this first, and keep it current.
DEV_NOTES.md Screen → hook → route → handler → table, per module, plus every worker. Its migration list is out of date.
docs/m0-functional-spec.md What M0 does feature by feature, including a known-gaps list.
docs/m0-hardening.md The security and performance passes: what was found, what was fixed, which test proves it. Do not undo these without reading it.
docs/setup-artha-fintech.md Worked runbook: set up a company, its customers and their Azure subscriptions.