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.
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 areOpsLayer.Api,OpsLayer.Domainand so on, and the database isopslayer. "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.
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.
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 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.
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.
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.
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. |
OpsDbContext is split into partials (OpsDbContext.M5.cs and so on). Handlers depend on a module interface, and the interfaces inherit: IOpsDb ← IHrDb/IAccDb ← ICrmDb ← ICloudDb/IDeliveryDb ← ISupportDb. 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.
m5.customer_connection. Cross-schema id columns are plain columns with no EF foreign key. Keep new links unenforced the same way.Raise("opslayer.mX.thing.happened", …), which writes to m0.outbox; a worker polls it and uses m0.inbox for idempotency. Modules never call each other's handlers — the one exception is M7 escalation calling M1's WorkItemBuilder.PeriodicWorker subclasses, registered under Workers:Enabled. Each waits 30 seconds before its first tick, then runs each tick in its own scope as a system principal.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:
Roles.Contains("Admin") anywhere. Permission keys only. A source scan fails the build if you try.IsPlatformAdmin is the only bypass — group scope plus m0.platform.admin. Never add a second shortcut.*, m0.platform.admin, m0.tenant.manage, m0.grant.write) are never carried by a company-scoped grant.| 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.
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.
ScopeHelper.EnsureCompanyAccess(p, r) has to be the literal first statement of the handler, with the parameter named exactly as the scan expects. Rename the parameter and the build fails."connectors" HttpClient: no redirects, public IPs only (re-resolved to defeat DNS rebinding), and .RemoveAllLoggers(). Connector URLs carry credentials in the path. A test proves the leak returns if you remove that call.dotnet user-secrets locally and environment variables in deployment. A source scan checks every appsettings*.json for credential-shaped keys.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 *.
| 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 |
| 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 |
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.
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.
dotnet build src/backend/OpsLayer.sln -c Release # what CI runs
dotnet test src/backend/OpsLayer.sln
npm run typecheck # the only web gate
Application only, so nothing there can touch a database. They cover rule classes, state machines and parsers.opslayer_it_<guid> database on the server named in OPSLAYER_TEST_PG, and drop it afterwards. Clients start real sessions and complete step-up with a software passkey, so signatures are genuinely verified. Only Microsoft's token validation is faked.OPSLAYER_PERF=1) because they seed ~170k rows.These scan the repository itself, in tests/OpsLayer.UnitTests/SecurityTests.cs. New code must satisfy all five:
FromSqlRaw/ExecuteSqlRaw, no NpgsqlCommand from an interpolated string. Use interpolated FromSql, or a const statement with bound parameters.ScopeHelper.EnsureCompanyAccess(p, r) as the literal first statement and the parameter named exactly.ConnectionStrings:, must be empty.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_…).
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.
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.
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:
OpsLayer.Domain/Mx/. Raise outbox events for anything another module cares about.[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.OpsDbContext.Mx.cs partial; workers and external clients in Infrastructure/Mx/.dotnet ef migrations add <Name> -p OpsLayer.Infrastructure -s OpsLayer.Api. Generated from the model, never hand-written.Api/Seed/Seeder*.cs.injectEndpoints, screen in src/features/<module>/, route in src/main.tsx.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.
EffectiveScope, ScopeResolver, CrmAccess.VisibleAsync or any behaviour needs an integration test that proves the boundary holds. If it cannot be run, say so out loud rather than shipping quietly.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. |
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.
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:
docs/m0-hardening.md;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.
| 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 |
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.
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.
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:
ScopeKind.Customer existed in the model but no request implemented TargetCustomerTenant, so it was dead code. That one finding changed the whole design.CrmAccess.VisibleAsync — because M5, M6 and M7 all read through it. One enforcement point, not fifteen sprinkled checks.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.That last point is the habit worth copying. An unverified claim is worse than a known gap.
| 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. |