# 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:

```mermaid
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.

```mermaid
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: `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`.

### How modules talk to each other

- **Shared reads.** Any module may query another's tables directly — M6 and M7 both read `m5.customer_connection`. Cross-schema id columns are plain columns with **no EF foreign key**. Keep new links unenforced the same way.
- **Events, not calls.** To notify another module, a domain entity calls `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`.
- **Background work.** `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.

## 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

- **Company-targeted M0 handlers must re-check.** `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.
- **Outbound calls go through the `"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.
- **Secrets are never in the repo.** They live in `dotnet user-secrets` locally and environment variables in deployment. A source scan checks every `appsettings*.json` for credential-shaped keys.

## 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:

```bash
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:

```bash
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

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

- **Unit tests** reference `Application` only, so nothing there can touch a database. They cover rule classes, state machines and parsers.
- **Integration tests** boot the real API in a throwaway `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.
- **Performance tests** are opt-in (`OPSLAYER_PERF=1`) because they seed \~170k rows.

### 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

```mermaid
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. **Migration** — `dotnet 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

- **It guessed instead of checking.** "Don't guess — query the database / read the file and tell me what it actually says." It has tools; make it use them.
- **It fixed the symptom.** "That hides the problem. What is the root cause?"
- **It claims something works.** "How did you verify that?" A good answer cites a command and its output.
- **It went beyond the ask.** Say so. Scope creep in a security-adjacent codebase is a real risk.

### Three things to be careful about

- **Security changes need proof, not reasoning.** A change to `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.
- **Migrations are irreversible in production.** Read the generated migration before it is applied. Check it does not recreate another module's tables.
- **It will happily write a plausible wrong answer about live state.** Anything about what is *deployed*, *configured* or *in the database* should be checked, not recalled.

## 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.

```mermaid
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:

- an **integration test that proves the boundary holds** — not just that the happy path works, but that the denied path is denied;
- a named reviewer who has read `docs/m0-hardening.md`;
- and if the boundary could not be tested end to end, **say so explicitly** in the review rather than letting it pass quietly.

## 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 |

### Where to read next

| 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. |
