Simiyon Vinscent Samuel · Engineering

All posts / Zoho Catalyst

  • Zoho Catalyst
  • FastAPI
  • LangGraph
  • Security
  • RBAC

Hardening a conversational crime-intelligence assistant on Zoho Catalyst

How a shortlisted Zoho hackathon assistant for state police works: a LangGraph retrieval pipeline over graph, RAG and SQL, Zia voice in three languages, and the hardening I did: a CORS wildcard guard, locks around the indexes that lost writes, and a security pass on rank-based RBAC and expiring tokens.

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.

One request touches five services. The numbered badges are the path of a typed question: browser, client, backend, a Signals event, the pipeline. The pipeline writes progress into NoSQL (5), which the backend streams back to the client.
The browser never waits on the pipeline directly. It waits on a job record. That decoupling is what lets the pipeline run on a different service with a different timeout.

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#

  1. An officer asks a question in plain language, typed or spoken, in English, Hindi or Kannada.
  2. The system retrieves matching FIRs from a case graph, a document knowledge base and a structured store.
  3. Every claim in the answer links to a record, with a confidence tier and the reasons behind it.
  4. Officers can log hypotheses, record alibis as negative evidence, and confirm or correct links, and those signals change later rankings.
  5. Cases and sessions are shared only with named collaborators.
  6. Officers log in with a rank, and what they can do depends on it.

Non-functional requirements#

  1. Trust. No answer without citations, and every answer carries an officer-verification line.
  2. Safety. Credentials never cross origins, tokens expire, ranks gate actions.
  3. Consistency. A write to an index must not silently drop another writer's entry.
  4. Latency. A slow source degrades the answer instead of blocking it: graph 5 s, RAG 15 s, SQL 4 s.
  5. Containment. Free text from queries and case records is data, never instructions to the model.
  6. Languages. Voice in English, Hindi and Kannada; evidence in other languages is translated before reasoning.
On the board so far
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.

Everything hangs off the FIR. Hypotheses and alibis point at it, and a case decides who may see it.

The interface#

The API is small. These are the routes that matter for this post:

Text
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-routes

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

The red tables are lists under a single key. They are the ones a concurrent writer can truncate. The blue, orange and green ones are written whole.
Every limit is a constant in the code. Keeping them as named constants is what makes them testable.

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.

Each node writes its own status. That is how the stream shows "Retrieving evidence" before the answer exists.
  1. The job event arrives. The first node writes understanding_query and extracts entities and intent from the query text.
  2. Entities are resolved against lookup tables for crime sub-heads and act sections.
  3. The planner builds the step list. If the planner fails, a default plan of one graph step and one RAG step is used.
  4. Retrieval runs every step concurrently, each under its own time budget. A step that times out is recorded as unavailable and the rest proceed.
  5. 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".

Agreement between independent sources is rewarded most. A record that only a semantic search found is never allowed to look as strong as one the graph also found.

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.

Voice and translation are two different hops. ASR turns speech into text before the pipeline; translation normalises evidence inside it.
  1. The client records and encodes WAV, then posts it to the transcribe route.
  2. The route reads at most 5 MB plus one byte and checks the MIME type, so the server never buffers an oversized body.
  3. Zia ASR returns text in the declared language.
  4. Inside the pipeline, evidence not tagged English is translated and the original text is kept alongside.
  5. 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.

Two layers answer two questions. The middleware asks who you are and what rank you hold. The case check asks whether you are on this case.

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.

The flaw is reflection, not the asterisk itself. With a stored session the page reads responses as the officer. The import-time guard removes the configuration that makes it possible. The origin shown is a placeholder.

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.

Both writers did the right thing and the data is still wrong. Ids h1, h2 and h3 are examples.
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.

The strategies shrink the window; only the lock removes it within a process. The bars are a teaching sketch, not measured values.

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.

Text
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]
There is no delete primitive, so revocation is an overwrite. The sentinel makes the very next lookup fail, and the record expires a second later.
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.

A cold query overruns the cut-off. The warm one fits with a few seconds to spare, and the status route covers the rest.

The final design#

Security is on the path, not around it. The new parts are the gate in front of the routes and the locks in front of the store.

One request, one rebuild#

  1. The client sends the question with a bearer token. The middleware loads the session and rejects it if it is missing, revoked or expired.
  2. The route applies the 30-per-minute rate limit and checks that the session id belongs to this officer.
  3. A job record is written and a Signals event published; the stream opens with the job id.
  4. The pipeline runs its stages under per-source budgets and writes each status as it goes.
  5. Evidence, claims and hypothesis indexes are written under their keyed locks.
  6. 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?

  1. The browser ignores credentials on cross-origin calls.
  2. The middleware reflected whatever origin the request sent, so any site could make credentialed calls.
  3. The server returned 500 for every request.
  4. Catalyst blocks cross-origin requests anyway.
B. Starlette does not reject the combination; it reflects the incoming Origin. The fix raises at import time.

Both officers got success responses, yet one hypothesis never appeared in the FIR list. What happened?

  1. The write was rate limited.
  2. The index was a list under one key and the later write overwrote the earlier one.
  3. NoSQL rejected the duplicate key.
  4. The hypothesis expired.
B. Read-modify-write without a lock: both read the same list, and the second write dropped the first id.

Why did calls to the existing lock helper not protect the indexes?

  1. The helper was never called.
  2. It recreated the lock on every call because its stale-loop check always fired.
  3. Locks do not work with async code.
  4. Indexes were stored in memory.
B. A lock's loop attribute is None in current Python, so the helper evicted and rebuilt the lock every time and no two callers shared one.

How is a session revoked when the store has no delete?

  1. The server restarts.
  2. The record is overwritten with a revoked sentinel and a one-second TTL.
  3. The token is added to a block list in memory.
  4. Sessions cannot be revoked.
B. The next lookup sees the sentinel and returns no session; the record then expires.

Why does the login path hash against a dummy value when the username is unknown?

  1. To save memory.
  2. So response time is the same whether or not the username exists.
  3. To generate a new salt.
  4. Because empty passwords are allowed.
B. Without it, unknown users returned faster and usernames could be enumerated by latency.

What lets a client recover when the stream is closed before the pipeline finishes?

  1. A longer HTTP timeout.
  2. The job id emitted first plus a status route that returns the finished result.
  3. Re-running the query automatically.
  4. Disabling the gateway.
B. The job completes in NoSQL regardless; the client fetches it by id.