I lead engineering at Nibras Legal Tech. We build a practice-management platform for law firms in Jordan: cases, court sessions, clients, documents, billing, and an AI legal assistant called Lexi. Many firms share one system, the interface is Arabic first, and the data is about as sensitive as data gets.
Most of the codebase was built and changed by one person, me, and that shaped every decision below. With no team to catch a forgotten check in review, the system has to catch it. So the theme is simple: make the safe path the default, and make the unsafe path fail loudly, preferably before the code runs.
The platform is private, so the snippets are simplified illustrations, not the production source. The numbers are counted from the codebase:
| What | Count |
|---|---|
| Database tables | 186 |
| API endpoints | 715 |
| Automated tests | 2,634 |
| Lines of application code | about 206,000 |
In short:
- One ASP.NET Core image on .NET 10, run twice: a web role that serves people and a worker role that does the slow work.
- Arabic and right-to-left are the default, and lint rules and tests keep them that way.
- A request pipeline where the order of the middleware is part of the security model.
- Firm isolation enforced on every query and every save, not remembered by each feature.
- The API refuses to start if any endpoint lacks an access rule, and one resolver computes every permission.
- Paid, slow work follows one pattern: reserve, claim, settle, refund. Nothing is charged twice.
- Lexi proposes. A person approves. Permissions are checked again at the moment it runs.
- Deploys back up first and roll back on their own when it is safe to.
One image, two roles
The system is deliberately small in moving parts. There is one PostgreSQL 17 database (with pgvector for the AI index, and the background job queue in its own schema), one S3-compatible object store, and one ASP.NET Core application. The React 18 single-page app is built into the same Docker image and served by the API itself, so there is no separate frontend deployment to keep in step.
That one image runs as two containers. An environment flag gives each a role: the web role serves the API, the realtime hubs and the SPA, and does not start the job server. The worker role runs the background jobs, OCR and PDF rendering under its own CPU cap. A slow document conversion never competes with a lawyer opening a case.
Inside the API, code is grouped by domain (access, documents, finance, matters, Lexi, OCR, plans and so on) rather than by technical layer. Each domain folder holds its controllers, services, jobs and DTOs together, and the React app mirrors the same names. It is a modular monolith in the plain sense: one deployable, with module boundaries that are checked in CI rather than enforced by a network. How those boundaries are kept honest is its own post: ratcheting architecture rules in a modular monolith.
One more shared piece ties the two stacks together: a single JSON route contract listing every screen, its path, and the permission key each action needs. The API derives its permission catalog from it, and the web app builds its router from it. A new screen is added once, and both sides pick it up.
Startup is strict too. In production the API refuses to boot if the database, the signing secret, the app URL, mail or storage settings are missing or weak. It never migrates, repairs or seeds the database on startup; schema changes only happen through explicit deploy commands.
Arabic first, enforced by tooling
Most software adds Arabic at the end, and it breaks in a hundred small places. In Nibras, right-to-left is the default and English, French and Spanish are the additional locales. Layouts use logical spacing and alignment so they work in both directions, and pull requests are checked in Arabic and English, RTL and LTR.
The rules that matter are enforced by tools rather than by memory:
- Every locale defines every key. A test requires all four locale files to define the union of all keys, so a missing translation fails the build instead of showing a raw key to a lawyer.
- Hard-coded Arabic is ratcheted. A lint step counts quoted Arabic strings that are not passed through a translation helper and compares the count with a committed baseline. It fails when the count rises, and also when it falls without the baseline being updated, so progress is recorded in the same change that makes it.
- Formatting goes through one helper. Dates and money use a shared formatter (Latin digits in Arabic text, Jordanian dinars with three decimal places), and a lint rule warns on locale-dependent formatting anywhere else.
- Dates are business dates. Deadlines, overdue invoices and document dates use the legal date in Amman, not UTC.
Arabic search, where one name has several spellings, is covered in the RAG post linked below.
The pipeline is the security model
In ASP.NET Core, middleware order is easy to treat as plumbing. Here it is a list of decisions, and each position has a reason.
A few of those reasons:
- The error handler sits near the top so every failure becomes an RFC 7807 problem response. Database errors are mapped once for the whole API (a duplicate key is a 409, a value that is too long is a 400), and a 500 carries a correlation id, never the exception text.
- Authentication checks more than the signature. On every request the account must be active, the firm must be active, and the token’s authorization version must match the person’s current counter. More on that below.
- The firm context comes after authentication and the staff support context, because it is filled from the validated token and nothing else. A firm id sent by the client is never used.
- A 403 names the permission that was missing, never the set the caller does hold.
- The rate limiter runs after authentication so it can partition by user rather than only by IP, with tighter limits on sign-in style endpoints and on AI chat.
Isolation by construction
When many firms share one database, “remember to filter by firm” is not a security model. Somebody forgets once, and one firm sees another firm’s cases. The firm boundary lives in the data layer, in one EF Core context, where no feature can skip it:
- Queries. Every tenant-owned entity implements a marker interface. At model build time, a loop over all entity types installs a global query filter on each one: the row’s firm must equal the firm in the request context. A new table gets the filter by implementing the interface. Soft-deletable entities get a second filter that hides deleted rows.
- Saves. On insert, the firm id is overwritten with the context’s value, whatever the client sent. On update or delete, the stored row’s firm must equal the context and the firm column must not have changed, or the save throws.
- Deletes. Soft-deletable entities get a flag instead, and deleting anything still referenced is refused with a 409.
- Escape hatches have names. Code that genuinely needs to see past the filter, such as sign-in, calls a helper that names its reason. It is easy to search for and obvious in review.
- Background jobs declare their scope. A job has no request, so its firm context is empty. A job either runs inside an explicit platform scope or sets the firm it works for. A test scans the source and fails the build when an entry point does neither.
- Realtime calls are scoped too. A global hub filter sets the firm from the connection’s own token claim on every connect and every method call.
The same idea runs through the people model. A person, a login, a firm membership and a firm’s private notes about that person are different things. Platform staff live in a separate realm from firm users, an email can exist in only one realm, and realtime user ids carry a realm prefix so a staff id can never receive a firm user’s push.
Authorization that fails closed
With 715 endpoints, “we’ll add the permission check later” is how you ship an open door. I designed the authorization layer around three rules.
Every endpoint declares its audience, or the API does not run. Each endpoint carries one kind of gate: a permission key, any of a set of keys, an explicit signed-in-only mark, platform staff only, or anonymous. The check runs at startup in every environment and again in the test suite:
// Simplified: refuse to start if any endpoint has no explicit access decision.
var unguarded = app.Services.GetRequiredService<EndpointDataSource>().Endpoints
.OfType<RouteEndpoint>()
.Where(e => !HasExplicitAccessDecision(e.Metadata)) // a bare [Authorize] does not count
.Select(e => e.RoutePattern.RawText)
.ToList();
if (unguarded.Count > 0)
throw new InvalidOperationException(
$"Endpoints without an access rule: {string.Join(", ", unguarded)}");
Who can reach what is pinned in golden files. Three committed text files list the audience of every endpoint, every Lexi tool, and the permissions a firm is allowed to hold. A change that widens or narrows access fails the test until the file is regenerated, and the diff is read line by line in review. A new + line is a person who can now do something. Every change to who can do what also needs a negative test, because an all-green suite does not notice that access got wider.
One resolver computes every permission. Enforcement on the server, the capabilities endpoint the UI reads, and the permission preview on the user-edit screen all call the same code. If display and enforcement were computed separately, a screen would eventually show a button the API refuses, or worse, the reverse.
A firm is organized into departments and sections. Each colleague gets one department, one section and one role, where a role is only a name and a set of operations: add, edit, delete, cancel, print, finalize, export. The section decides which screens a person sees, and the role decides what they can do on them. Then:
- No assignment means no access, not everything.
- The plan is a ceiling. Nothing a firm has not bought can be granted, and a plan with no screens linked gives an empty ceiling plus a small floor (profile, upgrade), never unrestricted access.
- You cannot give what you do not hold. Delegation is capped at the grantor’s own access.
- No role checks in feature code. Features ask “can this person do X here?”, never “is this person an admin?”. A test fails the build if role checks come back.
Writing a person’s exceptions is a full replace inside a serializable transaction. It first claims the person’s authorization version with a conditional update, so two admins editing the same person at once cannot silently overwrite each other. The loser gets a 409.
Sessions you can actually revoke
Access tokens live 10 minutes by default, and startup refuses any setting outside 5 to 15. The refresh token is a random value in an HttpOnly cookie that lasts 7 days, and the server stores only its SHA-256 hash, one row per device. A database leak does not hand out working sessions.
Every refresh spends the current row and inserts its successor in a single conditional update. If two requests present the same token at the same moment, exactly one wins and the other is treated as a replay. A replayed token means someone has a copy they should not have, so the whole family of tokens for that device is revoked. The account’s other devices stay signed in.
Each person also carries an authorization version, and every token is stamped with it. Any change to what someone may do bumps the counter, and the very next request with an old token gets a 401. The browser refreshes, receives a token with the new version and the new permissions, and carries on. Removing an employee’s access takes effect now, not whenever their token happens to expire. A password change, a reset, deactivation, or deactivating the whole firm goes further and revokes every device.
The browser side needed as much care. All tabs share one rotating cookie, so if two tabs refreshed at once, the second would look exactly like a replay. Tabs take turns through the browser’s Web Locks API, and concurrent requests inside a tab share a single refresh. When the signed-in identity changes, the client-side query cache is cleared before the new user renders, so one account’s data never flashes up for another. Sign-in answers the same way whether or not an account exists, and platform staff need a password plus an emailed code.
Background work that never charges twice
Job delivery is at-least-once, so every job has to be safe to run twice. Recurring jobs (hearing reminders 24 hours and 1 hour before a session, credit refills, period-end plan changes, retention cleanup) are registered by a deploy command rather than at startup, and each one is repeat-safe through a marker row, a cursor saved in the same transaction as its notification, or a stable reference id.
Money and credits follow one rule: every debit and every refund carries a reference, and the ledger has a unique index on it. A retry finds the original transaction instead of creating a second one, and a database check constraint makes a negative balance impossible.
OCR is the clearest example, and it became the template for any paid, slow, retryable work. A scanned PDF from a firm user goes through this:
The claim is the important line. A plain read-then-write would let two workers process the same job. A single conditional update cannot:
// Simplified: claim a queued job atomically. A cancelled job can never reach the provider.
var claimed = await db.OcrJobs
.Where(j => j.Id == jobId && j.Status == JobStatus.Queued)
.ExecuteUpdateAsync(s => s.SetProperty(j => j.Status, JobStatus.Processing));
if (claimed == 0) return; // someone else has it, or it was cancelled
Page counts read from a PDF’s structure are not always right, which is why the job settles on the provider’s real count afterwards. If a restart leaves a job stuck in processing for 30 minutes, a recurring job fails it and refunds it, and because the refund is keyed to the job, running the reaper twice adds nothing.
Storage follows the same instinct. It is private by default: one list of managed prefixes decides what is public (logos, profile pictures, official gazettes), everything else goes through authorized endpoints, and uploads are checked by magic bytes rather than the declared content type.
A legal library, and an assistant that has to ask first
Platform staff maintain a national library of Jordanian law: official gazette issues, the legislation published in them, the articles inside each law as a tree of any depth, court decisions, and the amend and repeal relationships between texts. It is global reference data, owned by no firm, and it is Lexi’s verified source of law. Retrieval over it gets its own post: Arabic legal RAG on PostgreSQL and pgvector.
Lexi can read (search the law, summarize the open case, read saved facts) and it can propose changes (create a task, a deadline, a client, or remember a fact). The most important brake is that the model never changes data on its own.
The approval is not a “yes” button floating free of context. It is a one-time token bound to that exact run, step, tool and set of arguments:
token = 32 random bytes, shown once
stored = sha256(token), run, step, tool, sha256(arguments),
expires_at = now + 15 min
confirm = verify hashes and expiry -> re-check permission and case access
-> claim the approval with one conditional update -> execute
If the arguments change after approval, the hash no longer matches and nothing runs. A double click, a retry or a second tab loses the claim. Platform staff have no firm, so mutating tools are not even offered to them. The rest of the guardrails follow the same thinking:
- Retrieval never leaves the caller’s firm, or the cases that user is allowed to open. The filter is part of the SQL query itself, not an instruction to the model.
- Memory writes need approval, because a saved fact is replayed into every later prompt, and a poisoned web page must not be able to plant one silently. Saved facts are labelled as untrusted user data when they go back into a prompt.
- Web results are data, not instructions. They go in as a delimited block on the user side of the conversation, never as a system message, and they are labelled unverified.
- No verified source, no answer. If a legal question cannot be grounded in the library, the turn stops with a clear message instead of answering law from memory.
- The server always gives up first. A turn has a 150-second budget, inside the browser’s 240 seconds and the proxy’s 300, so the server can refund the credit and return a clean error before anything upstream times out. A failed turn is discarded, not saved as an answer.
Reviewing a design: reuse beats parallel systems
Not all of the job is writing code. A recent example: a proposal arrived for a central archive, a single home for every file in the system, so that cases, sessions, Lexi conversations and the legal library all point at one stored file instead of keeping their own copies. The goal was right. My review was mostly about what already existed.
The platform already had a storage service, an archive record, an access resolver, an audit log, a background job system and an OCR pipeline. A parallel version of any of them would drift from the original within months. So the reviewed design added two tables instead of five, introduced no parallel services, and needed no dual-read migration or feature flag. A few of its decisions generalize well:
- Compute, do not store, what can drift. A stored reference count goes wrong after the first failed write. Counting links at delete time is always right and costs one indexed query.
- Deduplicate inside a firm only. Files are fingerprinted with SHA-256 and stored once per firm. Comparing across firms would tell one firm what another holds.
- Delete safely. Removing the last link marks the file for removal in the same transaction, and a background job then deletes the bytes, then the row. There is no moment when a link points at a deleted file.
- Enforce privacy in retrieval. Knowing a document exists is not permission to read it. Passages a user may not see never reach the model, so nothing depends on the model’s instructions to hide them.
Shipping without crossing your fingers
The API exposes health at three depths: live (the process answers), ready (the database is reachable and no migration is pending), and deep (adds object storage and the job system). Missing AI configuration reports as degraded rather than unhealthy, because a lawyer can still work when the assistant is down.
Production deploys only from main, through a workflow a person starts by hand. It waits for CI to pass on that exact commit, runs one deploy at a time, and is never cancelled halfway.
The readiness gate is where the safety lives. If the new version does not come up, the workflow checks whether the schema moved. If it did not, the previous image comes back automatically. If it did, an automatic rollback would be unsafe, so a person decides between restoring the dump and rolling forward.
Development happens somewhere else entirely: a virtual machine on office hardware, reachable only over Tailscale. Developers work in per-ticket copies of the repository, from accounts with no internet access, against a sanitized development database. Dev data is never production data, and there is no route from the development box to production at all. I wrote up that setup in working with contractors without handing over the codebase.
One lesson from CI: running every job on every change used 1,900 of our 2,000 monthly CI minutes in twelve days. Change detection now decides which jobs a commit needs, diffing against the last commit whose CI passed rather than the previous push, so a red backend commit followed by a frontend-only fix still reruns the backend.
What I would tell myself on day one
- Decide isolation and authorization before features. Retrofitting either one touches everything.
- Make the unsafe path fail loudly, and early. Refusing to start beats a forgotten check. A failing test beats a review comment.
- Keep one place for every hard rule. One resolver for permissions, one data context for firm scope, one pattern for paid work, one gate for deploys.
- Prefer one conditional update to a read and a write. Most of the concurrency bugs I did not have came from that habit.
- Reuse before you add. The most valuable review comment I wrote this year was “we already have this”.
If you are building something with the same constraints (many tenants, sensitive data, Arabic users, and AI that touches real records), I’m happy to compare notes. You can reach me through my site.