A police officer in the field does not have time to write a Cypher query. The Conversational Crime Intelligence System lets a Karnataka State Police officer type or speak a question in English, Hindi or Kannada and get back a report where every claim cites a case record and carries a confidence tier. It was a shortlisted entry in a Zoho hackathon, built by a team on Zoho Catalyst. I joined the backend side late in the build and spent most of my time making it safe to put in front of real officers: closing a CORS flaw, closing the races that silently dropped index entries, and putting rank-based access control and expiring session tokens in front of every route. This post walks the system one requirement at a time and then goes deep on those fixes.
Understand the problem#
The brief was a conversational assistant for state police. The test of it is not whether it talks, it is whether an investigating officer can trust what it says and whether a constable can see only what a constable should. I did not design the retrieval pipeline; a teammate built it. What I took on was the question of what happens when it meets real traffic and real adversaries, so the requirements below are written from that side.
Functional requirements#
- An officer asks a question in plain language, typed or spoken, in English, Hindi or Kannada.
- The system retrieves matching FIRs from a case graph, a document knowledge base and a structured store.
- Every claim in the answer links to a record, with a confidence tier and the reasons behind it.
- Officers can log hypotheses, record alibis as negative evidence, and confirm or correct links, and those signals change later rankings.
- Cases and sessions are shared only with named collaborators.
- Officers log in with a rank, and what they can do depends on it.
Non-functional requirements#
- Trust. No answer without citations, and every answer carries an officer-verification line.
- Safety. Credentials never cross origins, tokens expire, ranks gate actions.
- Consistency. A write to an index must not silently drop another writer's entry.
- Latency. A slow source degrades the answer instead of blocking it: graph 5 s, RAG 15 s, SQL 4 s.
- Containment. Free text from queries and case records is data, never instructions to the model.
- Languages. Voice in English, Hindi and Kannada; evidence in other languages is translated before reasoning.
Officer -> React client -> FastAPI (AppSail) -> NoSQL job record + Signals event -> Function: LangGraph pipeline -> Memgraph / KB / Data Store Needed next: citations, voice, who-can-see-what
The set-up#
Core entities#
The graph holds FIRs, accused persons, victims and the edges between them, plus phone, account and vehicle records ingested from call-detail, financial and number-plate datasets. Around that, NoSQL holds the application state: users, sessions, jobs, conversation history, hypotheses, claims, exclusions, review items and cases.
The interface#
The API is small. These are the routes that matter for this post:
POST /api/auth/login public: returns a bearer token (8 h TTL)
POST /api/auth/logout revokes the token now
POST /api/query SSE stream: job, progress, evidence, token, done
GET /api/query/status/{job} fetch a finished job if the stream died
POST /api/warmup pre-warm the pipeline Function
POST /api/transcribe audio -> text (Zia ASR)
POST /api/tts text -> audio (Zia TTS)
POST /api/export/pdf report export, rank sub_inspector or above
POST /api/cases create a case; collaborators gate all sub-routesThe data model#
Catalyst NoSQL is used as a key-value table with a TTL attribute. Rather than create more tables in the console, each kind of record is a key prefix. This is convenient and it is also the source of the race conditions later: an index is just a JSON list under one key.
Build it one requirement at a time#
1. Ask a question and get a cited answer#
The first requirement is the whole pipeline. The graph has seven nodes: understanding, entity resolution, planning, retrieval, optional translation, confidence scoring, visualisation and synthesis. Retrieval is planned as a small DAG by an LLM call that returns steps of type graph, rag or sql, with dependencies. For lookups it always asks for graph and RAG together, so RAG is a safety net when the graph is sparse.
- The job event arrives. The first node writes
understanding_queryand extracts entities and intent from the query text. - Entities are resolved against lookup tables for crime sub-heads and act sections.
- The planner builds the step list. If the planner fails, a default plan of one graph step and one RAG step is used.
- Retrieval runs every step concurrently, each under its own time budget. A step that times out is recorded as unavailable and the rest proceed.
- Evidence is scored and ranked, then the synthesis node writes the answer with the reasoning trace in its prompt.
2. Evidence you can audit#
Each evidence item is a FIR with the sources that found it. Its confidence score is a weighted sum: 45 percent source convergence, 40 percent evidence strength and 15 percent recency. A FIR found by both graph and RAG scores 1.0 on convergence; graph alone 0.75; SQL 0.65; RAG alone 0.55. Strength depends on the path: a direct co-accused link is 1.0, a shared vehicle or phone contact 0.85, shared method 0.75, a shared tattoo 0.65 with a flag that it is not forensically confirmed, temporal proximity alone 0.55 with a flag that proximity does not establish connection. The score maps to a tier: high at 0.80, medium at 0.60, low at 0.40, and anything flagged and below 0.70 is "unverified".
Two further signals reshape the ranking. A methodology trust weight, learned from officer confirmations and corrections, multiplies the score: it is smoothed with a prior of 0.7 at a strength of 10 observations, and floored at 0.3, so a handful of corrections cannot erase a link type. And an alibi recorded against an accused demotes that FIR by a factor of 0.05. It sinks to the bottom but stays visible, because the system should never silently delete a line of inquiry.
3. Voice in three languages#
Voice input goes browser to /api/transcribe to Zia ASR, which supports English, Hindi and Kannada. Two details cost time. Zia validates the format by file extension and rejects .webm, which is what Chrome's MediaRecorder produces, so the client encodes WAV in the browser. And Zia's text-to-speech requires a speaker name that is valid for the language, so each language has a default. Evidence in other languages goes the other way: a Unicode-range check tags each narrative as Kannada, Hindi or Tamil, and anything not English is translated through Zia Translate before scoring.
- The client records and encodes WAV, then posts it to the transcribe route.
- The route reads at most 5 MB plus one byte and checks the MIME type, so the server never buffers an oversized body.
- Zia ASR returns text in the declared language.
- Inside the pipeline, evidence not tagged English is translated and the original text is kept alongside.
- Reports can be read aloud with TTS, using a default speaker for the chosen language.
4. Who may do what#
Authentication is a login route, a session store and a middleware. The design is covered in a deep dive below. On the board it adds one box in front of everything and one lookup behind it.
Deep dive: a wildcard origin with credentials#
The browser rule is that a credentialed response cannot carry Access-Control-Allow-Origin: *. A server that follows the spec would refuse the combination. Starlette's middleware does not refuse it. It falls back to reflecting whatever Origin header the request sent, which is functionally "any site may make credentialed requests". The default list in the repo was a safe localhost allowlist, so nothing was exploitable at the time, but one copied "quick fix" in a deploy config would have opened it silently.
Trust the default
The default is narrow, so leave it. This works until a deploy config is edited under pressure, and nothing would notice, because the server would keep returning 200s with reflected origins.
Strip the wildcard at runtime
Drop the * and carry on with whatever is left. This is safer, but it hides a bad configuration: the operator believes everything is allowed and some clients mysteriously fail.
Refuse to start
Raise at import time if the list contains *. A misconfiguration becomes a failed deploy that a human sees in the first minute, which is the cheapest place to find it. I added a test that sets the variable and asserts the import raises.
There is a second CORS trap on Catalyst. The AppSail gateway reflects the request origin on its own responses. Adding our own CORS middleware on top produced two Access-Control-Allow-Origin values on one response, which browsers reject, so every cross-origin call broke. The middleware is now added only when the process is not running inside AppSail, detected by the platform's listen-port variable.
Deep dive: index races that dropped records#
Only one id survives, and a retry cannot fix it, because neither caller sees an error. The hypothesis record exists under its own key but never appears in the FIR's list again. The same shape existed in the claim logger, where the index feeds contradiction detection: a claim missing from the index silently disabled the check for that claim. NoSQL here has no compare-and-set, so the write itself cannot be made atomic.
Retry on failure
There is no failure to retry. Both writes returned success.
Write, read back, retry
The review queue index does this: write, re-read, check that the id stuck, and retry up to five times, merging whatever a concurrent writer added. It narrows the window to a few milliseconds without closing it, and the code says so in a comment. It is the right tool where there is no shared lock, because the writers may be in different processes.
A per-key lock around the whole read-modify-write
Within one process, an asyncio.Lock keyed by the index key serialises the critical section. My change moved the hypothesis index, the claim index and the hypothesis resolve path inside it.
The catch is the one that made this interesting. The codebase already had a get_lock(key) helper and every index write already called it, so the races looked fixed. They were not. The helper tried to detect a stale event loop by comparing a lock's internal loop attribute to the current loop. In current Python versions a lock no longer binds to a loop when it is created, so that attribute is always None, the "stale" branch fired on every call, and the helper evicted and recreated the lock each time. Two coroutines asking for the same key got two different, uncontended locks, and async with excluded nothing. Every call site across sessions, cases, indexes, trust counters and query ownership was silently unprotected. A teammate had already replaced it with plain least-recently-used caching by key, which is what made my fix to the remaining unlocked indexes meaningful: the hypothesis index, the claim index and the resolve path were still writing without any lock at all.
Deep dive: rank-based RBAC and expiring tokens#
The original middleware was a pass-through: every request reached every route. The replacement, written earlier by me with the rank hierarchy and later extended in the security pass, is an ASGI middleware. It lets two paths through unauthenticated, /health and the login route. For everything else it reads the Authorization header, decodes it with replacement rather than strict UTF-8 so a malformed header yields a clean 401 instead of an unhandled error, and loads the session. The session record carries the username and role, and it was written with a TTL of eight hours, one shift. The middleware then copies the identity into scope["state"], which is where routes read request.state.username.
Rank is an ordered list from DySP down to Constable. A route can declare a minimum rank; today the PDF export needs sub-inspector. The comparison is index order, lower index meaning more authority, and an unknown role never passes.
ROLE_HIERARCHY = ["dysp", "inspector", "sub_inspector",
"asi", "head_constable", "constable"]
def role_meets_minimum(role, minimum_role):
if role not in ROLE_RANK or minimum_role not in ROLE_RANK:
return False
return ROLE_RANK[role] <= ROLE_RANK[minimum_role]Check the token inside each route
Fifteen routers means fifteen chances to forget. The original state was effectively this: identity was not available to routes at all.
A FastAPI dependency
Better, but still opt-in per route. A new route added without the dependency is public by default.
One middleware, deny by default
Only two paths are public. A new route is protected the day it exists, and the only way to make one public is to edit a single set.
Login got the same treatment. Passwords are hashed with PBKDF2 and a per-user salt. The login path always runs one full hash comparison, against a fixed dummy hash when the username does not exist, so response time does not reveal which usernames are real. Five consecutive failures for a username lock it for fifteen minutes, with the counter keyed on the submitted name whether or not the account exists. On the case side, a missing case and a case you are not on both return the identical 403, so case ids cannot be enumerated by status code.
Deep dive: a pipeline that outlives its stream#
The pipeline would finish and write its answer to NoSQL, but the stream was already closed, so the interface sat on its last stage forever. Three changes fixed it. The stream now emits the job id as its first event. A new status route returns a finished job on request, with a generic message on failure. And a warm-up route fires a no-op event at the Function so its container is hot before the first real query. The poller also backs off, from half a second towards five seconds, and keeps a ping event flowing so the connection is not treated as idle.
The final design#
One request, one rebuild#
- The client sends the question with a bearer token. The middleware loads the session and rejects it if it is missing, revoked or expired.
- The route applies the 30-per-minute rate limit and checks that the session id belongs to this officer.
- A job record is written and a Signals event published; the stream opens with the job id.
- The pipeline runs its stages under per-source budgets and writes each status as it goes.
- Evidence, claims and hypothesis indexes are written under their keyed locks.
- The stream delivers evidence, visualisation and the answer, or the client fetches the finished job from the status route.
What I would change#
The locks are per process, and the backend and the Function are separate processes, so they still cannot exclude each other; the right fix is a store with conditional writes or a single owner per index. Most routes ask only for a session, and the job status route returns any job to any signed-in officer who knows its id, so job ownership should be checked as session ownership already is. The input validator's pattern denylist for SQL and Cypher shapes is a blunt instrument, and parameterised queries are the real protection. And the rate limit counter is a read-modify-write too, protected by the same in-process lock, so it has the same limit. Finally, the audit behind these fixes was merged once, reverted, and then restored on 25 July as a re-audited change; I would break a security pass into smaller pull requests so a revert does not take every fix with it.
Test yourself#
Why was setting the allowed origins to a literal asterisk dangerous even though the CORS spec forbids it with credentials?
- The browser ignores credentials on cross-origin calls.
- The middleware reflected whatever origin the request sent, so any site could make credentialed calls.
- The server returned 500 for every request.
- Catalyst blocks cross-origin requests anyway.
Both officers got success responses, yet one hypothesis never appeared in the FIR list. What happened?
- The write was rate limited.
- The index was a list under one key and the later write overwrote the earlier one.
- NoSQL rejected the duplicate key.
- The hypothesis expired.
Why did calls to the existing lock helper not protect the indexes?
- The helper was never called.
- It recreated the lock on every call because its stale-loop check always fired.
- Locks do not work with async code.
- Indexes were stored in memory.
How is a session revoked when the store has no delete?
- The server restarts.
- The record is overwritten with a revoked sentinel and a one-second TTL.
- The token is added to a block list in memory.
- Sessions cannot be revoked.
Why does the login path hash against a dummy value when the username is unknown?
- To save memory.
- So response time is the same whether or not the username exists.
- To generate a new salt.
- Because empty passwords are allowed.
What lets a client recover when the stream is closed before the pipeline finishes?
- A longer HTTP timeout.
- The job id emitted first plus a status route that returns the finished result.
- Re-running the query automatically.
- Disabling the gateway.
