<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Manjunath</title>
    <description>The latest articles on Forem by Manjunath (@manjunath_d35c391da339e5b).</description>
    <link>https://forem.com/manjunath_d35c391da339e5b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3938862%2F7c8cca08-7a27-4f62-9d2d-c83ef13beb19.png</url>
      <title>Forem: Manjunath</title>
      <link>https://forem.com/manjunath_d35c391da339e5b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/manjunath_d35c391da339e5b"/>
    <language>en</language>
    <item>
      <title>What Enterprise RAG Is Ready For Today and What Production Deployment Actually Requires</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Fri, 22 May 2026 19:31:00 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/what-enterprise-rag-is-ready-for-today-and-what-production-deployment-actually-requires-24jh</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/what-enterprise-rag-is-ready-for-today-and-what-production-deployment-actually-requires-24jh</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG — A practitioner's build log | Post 6 of 6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This series has documented a system built to a specific standard: one where access control is enforced before retrieval scoring, where every answer includes traceable citations, and where the evaluation set measures restricted document leakage rather than retrieval relevance alone.&lt;/p&gt;

&lt;p&gt;This final post answers the question that matters most for teams considering this as a foundation: what works today, what needs to be in place before this handles real internal documents in a production environment, and what the gap between those two states actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is fully operational today
&lt;/h2&gt;

&lt;p&gt;Every item below runs locally without external dependencies or provider credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document pipeline:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Markdown document ingestion with front-matter role metadata (&lt;code&gt;POST /ingest&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SQLite metadata and chunk store with document and chunk count metrics&lt;/li&gt;
&lt;li&gt;Lexical retrieval with token cosine similarity scoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Query and access control:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Role-based candidate filter applied before retrieval scoring&lt;/li&gt;
&lt;li&gt;Citation-backed answer generation in deterministic mock mode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RBAC_blocked_count&lt;/code&gt; logged per query — tracks how many chunks were filtered&lt;/li&gt;
&lt;li&gt;Role derivation from &lt;code&gt;X-API-Key&lt;/code&gt; header, preventing request-body role elevation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;API and authentication:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI query API (&lt;code&gt;POST /query&lt;/code&gt;) with health probes at &lt;code&gt;GET /health&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Local user registration with role assignment (&lt;code&gt;POST /auth/register&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;API key creation, listing, and revocation (&lt;code&gt;POST /api-keys&lt;/code&gt;, &lt;code&gt;POST /api-keys/{id}/revoke&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SHA-256 key hash storage — raw key never persisted after creation&lt;/li&gt;
&lt;li&gt;Management endpoint protection via &lt;code&gt;ADMIN_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Evaluation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Evaluation runner via &lt;code&gt;POST /eval/run&lt;/code&gt; — calls live query pipeline, not a mocked path&lt;/li&gt;
&lt;li&gt;Four metrics per run: pass rate, restricted leakage count, citation coverage, average latency&lt;/li&gt;
&lt;li&gt;Per-case results with expected vs. retrieved document IDs and pass/fail indicators&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Operational controls:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audit log for all administrative actions (&lt;code&gt;GET /audit-logs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Query log with citations, role, latency, and RBAC metrics&lt;/li&gt;
&lt;li&gt;Prometheus-style operational metrics endpoint&lt;/li&gt;
&lt;li&gt;Security headers enabled by default, CORS explicitly configured&lt;/li&gt;
&lt;li&gt;Structured JSON request logging (&lt;code&gt;JSON_LOGS=true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;In-memory rate limiting per client (&lt;code&gt;RATE_LIMIT_PER_MINUTE&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API-backed Streamlit dashboard — no direct database access from the UI&lt;/li&gt;
&lt;li&gt;Docker files for containerized runtime validation&lt;/li&gt;
&lt;li&gt;GitHub Actions CI workflow&lt;/li&gt;
&lt;li&gt;Azure AI Search retrieval adapter implemented and configuration-selectable&lt;/li&gt;
&lt;li&gt;OpenAI and Azure OpenAI generation adapters configuration-selectable&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Azure deployment path
&lt;/h2&gt;

&lt;p&gt;The local runtime maps directly to an Azure deployment topology:&lt;/p&gt;

&lt;p&gt;Employee → Microsoft Entra ID&lt;br&gt;
               ↓&lt;br&gt;
       Azure Container Apps: API + Dashboard&lt;br&gt;
               ↓&lt;br&gt;
       Azure AI Search (retrieval)&lt;br&gt;
       Azure OpenAI (answer generation)&lt;br&gt;
       Azure PostgreSQL or Cosmos DB (metadata + audit logs)&lt;br&gt;
       Azure Blob Storage (source documents)&lt;br&gt;
               ↓&lt;br&gt;
       Azure Key Vault (secrets)&lt;br&gt;
       Application Insights (logs + metrics)&lt;/p&gt;

&lt;p&gt;Switching from local to Azure requires environment variable changes only. No code changes. No schema migrations between SQLite and PostgreSQL — the SQLAlchemy layer handles both. Azure mode fails fast when required &lt;code&gt;AZURE_*&lt;/code&gt; settings are missing rather than silently degrading to a local fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What production deployment requires beyond the current implementation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Entra ID or OIDC role derivation from identity claims.&lt;/strong&gt; The local implementation derives role from API key registration. Production deployment should derive role from authenticated identity token claims — not from request parameters or static key registration. The &lt;code&gt;AUTH_PROVIDER=entra&lt;/code&gt; configuration path is implemented. End-to-end validation requires a live Azure tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic or hybrid retrieval.&lt;/strong&gt; The local lexical retriever is deterministic and validates access control correctly. It does not match the retrieval quality of embedding-based semantic search for queries without token overlap with document chunks. Azure AI Search vector and hybrid query modes are the planned production retrieval path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distributed rate limiting.&lt;/strong&gt; The in-memory rate limiter does not share state across multiple API instances. Horizontal scaling requires Redis-backed or API gateway rate limiting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PII classification and retention policies.&lt;/strong&gt; The reference document corpus is synthetic. Before ingesting real internal documents — HR records, finance reports, incident logs — the ingestion pipeline should classify content for PII, apply sensitivity labels, and enforce explicit data retention policies for stored queries and generated answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenant isolation.&lt;/strong&gt; The current implementation is single-organization. A deployment serving multiple business units with strict data isolation between them requires a tenant isolation layer at the data model and query pipeline level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Broader evaluation set.&lt;/strong&gt; The current evaluation set is calibrated for access-control validation across a small synthetic corpus. A production evaluation set requires human relevance labels, answer correctness checks, and a regression threshold integrated into the CI workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  An honest assessment
&lt;/h2&gt;

&lt;p&gt;Enterprise RAG demonstrates the architecture that matters for internal knowledge systems: pre-retrieval access control, citation-backed answers, and an evaluation standard that measures restricted document leakage. The local implementation is complete, testable, and fully reproducible without provider credentials.&lt;/p&gt;

&lt;p&gt;The gap to production is real and specific. Entra ID integration, semantic retrieval, distributed rate limiting, PII handling, and tenant isolation are well-understood engineering problems with clear solutions. None of them require rethinking the core pipeline — the access control order, the citation model, and the evaluation structure remain intact.&lt;/p&gt;

&lt;p&gt;For a team building an internal document Q&amp;amp;A system: the architecture here is worth adopting. The hardening list above is the production backlog, not a reason to start from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would implement next
&lt;/h2&gt;

&lt;p&gt;The highest-impact single item is Entra ID role derivation in production. The entire value of pre-retrieval access control depends on the role being trustworthy. In a local environment with API key role binding, that trust is reasonable. In a production environment with hundreds of employees, role must come from an authenticated identity provider — not from a manually registered key that may become stale when someone changes teams or leaves the organization.&lt;/p&gt;

&lt;p&gt;The concrete step: configure &lt;code&gt;AUTH_PROVIDER=entra&lt;/code&gt;, map Entra group claims to retrieval roles, and validate that the role filter receives the correct role from the token rather than from the request body. That single change makes the access control guarantee durable against organizational changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question for you
&lt;/h2&gt;

&lt;p&gt;When an employee changes roles or leaves your organization, how quickly does your internal knowledge system stop serving them documents from their previous role? Is that enforced at the identity provider level or at the document system level?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This concludes the Enterprise RAG build log series.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Full series index
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Access Control Gap That Makes Most Enterprise RAG Systems Dangerous&lt;/li&gt;
&lt;li&gt;How Enterprise RAG Is Structured: Why Access Control Comes Before Retrieval Scoring&lt;/li&gt;
&lt;li&gt;Three Design Decisions That Shaped the Enterprise RAG Retrieval Pipeline&lt;/li&gt;
&lt;li&gt;Four Metrics That Actually Tell You Whether Your Enterprise RAG Is Working&lt;/li&gt;
&lt;li&gt;Security Controls in Enterprise RAG: Keys, Audit Logs, and the Hierarchy That Prevents Role Elevation&lt;/li&gt;
&lt;li&gt;What Enterprise RAG Is Ready For Today and What Production Deployment Actually Requires &lt;em&gt;(this post)&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Security Controls in Enterprise RAG: Keys, Audit Logs, and the Hierarchy That Prevents Role Elevation</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Thu, 21 May 2026 21:46:00 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/security-controls-in-enterprise-rag-keys-audit-logs-and-the-hierarchy-that-prevents-role-3iaj</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/security-controls-in-enterprise-rag-keys-audit-logs-and-the-hierarchy-that-prevents-role-3iaj</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG — A practitioner's build log | Post 5 of 6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A knowledge search system for internal documents carries a specific security obligation: it must not make it easier to access restricted information than going to the document source directly. If an employee can ask a question and receive an answer that reflects finance data they are not authorized to see, the system has introduced a new attack surface that did not exist before.&lt;/p&gt;

&lt;p&gt;The security design in Enterprise RAG addresses this through a hierarchy of controls — not a single mechanism, but a layered set that each address a distinct failure point. This post documents what each control does, the tradeoff it accepts, and what remains explicitly unimplemented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 1: API key role binding — preventing request-body role elevation
&lt;/h2&gt;

&lt;p&gt;The query endpoint accepts a &lt;code&gt;user_role&lt;/code&gt; parameter in the request body. For unauthenticated local use, this is acceptable. In any shared or externally accessible environment, it is a security problem: a caller who knows the role parameter name can claim any role and retrieve documents outside their authorization.&lt;/p&gt;

&lt;p&gt;The control is API key role binding. When a request includes an &lt;code&gt;X-API-Key&lt;/code&gt; header, the role context for retrieval is derived from the key holder's registered role — not from the request body. The request body role is ignored entirely.&lt;/p&gt;

&lt;p&gt;User registers → POST /auth/register (role assigned at registration)&lt;br&gt;
Admin creates key → POST /api-keys (key is bound to user's role)&lt;br&gt;
Query with key → POST /query + X-API-Key: &lt;br&gt;
Role used for retrieval: key holder's registered role (request body role ignored)&lt;/p&gt;

&lt;p&gt;This closes the role elevation path for authenticated callers. A key issued to a user with &lt;code&gt;role: employee&lt;/code&gt; cannot retrieve finance documents even if the caller submits &lt;code&gt;user_role: finance&lt;/code&gt; in the request body.&lt;/p&gt;

&lt;p&gt;API keys are stored as SHA-256 hashes. The raw key is returned once at creation and never again. If a key is lost, it must be revoked and reissued — the stored hash cannot be reversed to recover the original value.&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 2: Key revocation — making access removal immediate
&lt;/h2&gt;

&lt;p&gt;API key revocation (&lt;code&gt;POST /api-keys/{api_key_id}/revoke&lt;/code&gt;) removes the stored hash. A revoked key is rejected by &lt;code&gt;POST /query&lt;/code&gt; on the next request — there is no grace period, no cache to drain, no session to expire.&lt;/p&gt;

&lt;p&gt;This is operationally important for two scenarios: an employee departure and a compromised credential. In both cases, the recovery action is immediate revocation rather than waiting for a session timeout or token expiry.&lt;/p&gt;

&lt;p&gt;The revocation endpoint requires the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; when management protection is enabled, which means the revocation action itself is authenticated. An unauthorized caller cannot revoke another user's key.&lt;/p&gt;
&lt;h2&gt;
  
  
  Control 3: Management endpoint protection — separating operational from query access
&lt;/h2&gt;

&lt;p&gt;A class of endpoints — ingestion, user registration, key creation, key listing, audit log access, and evaluation runs — are administrative by nature. In any shared or hosted environment, these endpoints must not be accessible without authentication.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; is set, these endpoints require &lt;code&gt;X-Admin-Token&lt;/code&gt; in the request header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- `POST /ingest`
- `POST /auth/register`
- `POST /api-keys`, `GET /api-keys`, `POST /api-keys/{api_key_id}/revoke`
- `GET /audit-logs`
- `POST /eval/run`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query endpoint (&lt;code&gt;POST /query&lt;/code&gt;) is governed separately by API key authentication. Query access and management access use different credentials with different scopes. A leaked query key does not grant management access. A leaked admin token does not include the role context of any specific user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 4: Audit logging — making every administrative action traceable
&lt;/h2&gt;

&lt;p&gt;Every management action writes a record to &lt;code&gt;audit_logs&lt;/code&gt;: which action was taken, when, and by which admin credential. The audit log is readable through &lt;code&gt;GET /audit-logs&lt;/code&gt; with admin authentication.&lt;/p&gt;

&lt;p&gt;The current scope of audit logging covers administrative actions. Query logs — which record the question asked, the role used, the citations returned, and the RBAC-blocked chunk count — are stored separately in the query log table and accessible through the dashboard.&lt;/p&gt;

&lt;p&gt;Together, these two logs answer the questions a security review will ask: who ingested documents, when were keys created or revoked, what was queried by which role, and what was blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Control 5: Security headers and CORS — default-on, not opt-in
&lt;/h2&gt;

&lt;p&gt;Security headers are enabled by default (&lt;code&gt;SECURITY_HEADERS_ENABLED=true&lt;/code&gt; in the base configuration). CORS origins are configured explicitly through &lt;code&gt;CORS_ORIGINS&lt;/code&gt; — no wildcard default.&lt;/p&gt;

&lt;p&gt;These are baseline controls that cost nothing and prevent a class of browser-based attacks. An API that stores internal document citations should not allow cross-origin requests from arbitrary origins.&lt;/p&gt;

&lt;p&gt;For Azure deployment, the CORS origin list should enumerate only the dashboard Container App URL and any internal tools that call the query API directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not yet implemented
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Entra ID and OIDC role derivation from token claims.&lt;/strong&gt; The &lt;code&gt;AUTH_PROVIDER=entra&lt;/code&gt; and &lt;code&gt;AUTH_PROVIDER=oidc&lt;/code&gt; configuration paths are implemented and validate bearer JWTs against issuer, audience, expiration, and JWKS signing keys. Role mapping reads from &lt;code&gt;roles&lt;/code&gt;, &lt;code&gt;groups&lt;/code&gt;, or &lt;code&gt;role&lt;/code&gt; token claims and defaults to &lt;code&gt;employee&lt;/code&gt; when no role claim is present. End-to-end validation requires a live Azure tenant — it is not testable in the local environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenant isolation for multi-organization deployments.&lt;/strong&gt; The current implementation assumes a single organization. Multi-tenant deployment — where organization A's documents are completely isolated from organization B — requires additional data model work and is a documented production consideration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PII classification for ingested documents.&lt;/strong&gt; The included reference documents are synthetic. Production ingestion should classify documents for PII content and apply explicit retention policies for prompts and generated answers before storing them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distributed rate limiting.&lt;/strong&gt; The current in-memory rate limiter (&lt;code&gt;RATE_LIMIT_PER_MINUTE&lt;/code&gt;) works correctly for single-instance deployments. Multi-instance production deployments require Redis-backed or API gateway rate limiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security posture in plain terms
&lt;/h2&gt;

&lt;p&gt;Enterprise RAG is designed for internal deployment by an engineering team with control over the document corpus, the user registry, and the infrastructure. The controls are appropriate for that context. The gaps — multi-tenant isolation, production-grade PII classification, distributed rate limiting — are appropriate for a larger managed deployment and are documented rather than hidden.&lt;/p&gt;

&lt;p&gt;Deploying this system to a shared or externally accessible environment without setting &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; is a configuration error, not an implementation gap. The controls are present. The operator must activate them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next engineering step
&lt;/h2&gt;

&lt;p&gt;Enable &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; in your local &lt;code&gt;.env&lt;/code&gt;, attempt to call &lt;code&gt;POST /ingest&lt;/code&gt; without the token, and verify the endpoint returns a 401. Then call &lt;code&gt;GET /audit-logs&lt;/code&gt; with the admin token and confirm the rejected attempt was logged. That sequence validates that management protection is enforced and that audit logging is capturing the right events.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question for you
&lt;/h2&gt;

&lt;p&gt;For internal tools that handle restricted documents, do you separate query credentials from management credentials? If a query key were compromised, could an attacker use it to ingest new documents or access the audit log?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Final post in this series: Deployment readiness — what is running locally, what the Azure path requires, and an honest list of what needs to be in place before this system handles real internal documents in production.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>rag</category>
    </item>
    <item>
      <title>Four Metrics That Actually Tell You Whether Your Enterprise RAG Is Working</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Thu, 21 May 2026 17:54:07 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/four-metrics-that-actually-tell-you-whether-your-enterprise-rag-is-working-48c6</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/four-metrics-that-actually-tell-you-whether-your-enterprise-rag-is-working-48c6</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG — A practitioner's build log | Post 4 of 6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;RAG evaluation in most implementations stops at one question: did the system retrieve the document that was supposed to be retrieved?&lt;/p&gt;

&lt;p&gt;That is the right question for a consumer search product. It is an incomplete question for an enterprise knowledge system where some documents are accessible to all employees and others are restricted by role. In that environment, retrieval correctness is necessary but not sufficient. You also need to know whether restricted documents leaked into answers they should not have influenced.&lt;/p&gt;

&lt;p&gt;Enterprise RAG implements four metrics. Each measures a different failure mode. Together they constitute a validation standard that is appropriate for internal knowledge systems handling mixed-sensitivity documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four metrics and what each catches
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pass rate&lt;/strong&gt; — the percentage of evaluation cases where expected documents were retrieved and forbidden documents were not.&lt;/p&gt;

&lt;p&gt;This is the composite metric. A case passes only when both conditions hold simultaneously: the expected documents appear in the citation set and no forbidden documents appear. A system that retrieves all expected documents but also leaks one forbidden document does not pass that case. Pass rate does not reward partial correctness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restricted leakage count&lt;/strong&gt; — the number of evaluation cases that returned at least one forbidden document in the citation set.&lt;/p&gt;

&lt;p&gt;This is the most operationally critical metric for an enterprise deployment. A restricted leakage count of zero means the role filter is working correctly across every test case in the evaluation set. Any non-zero value indicates a specific failure to investigate — which case, which document, which role, which query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Citation coverage&lt;/strong&gt; — the average number of citations returned per evaluation case.&lt;/p&gt;

&lt;p&gt;Low citation coverage indicates that the retrieval system is returning answers without grounding them in source documents. In an enterprise context, an answer without citations cannot be verified, audited, or traced back to a source document. Citation coverage is a proxy for answer auditability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Average latency (ms)&lt;/strong&gt; — the mean query execution time across all evaluation cases.&lt;/p&gt;

&lt;p&gt;Latency is not a quality metric. It is an operational baseline. If latency increases after a retrieval configuration change — switching from local lexical to Azure AI Search, adding a reranking step, increasing chunk count — the evaluation run captures it. Latency regression during evaluation is a signal worth investigating before deploying the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the evaluation output looks like
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;POST /eval/run&lt;/code&gt; returns all four metrics in a single response alongside the per-case results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pass_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"restricted_leak_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citation_coverage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"average_latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;38.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each case in the result array includes the question, the role used, the retrieved document IDs, and a pass/fail indicator. A failed case shows exactly which expected document was missing or which forbidden document leaked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwg9a888edghqw475cklq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwg9a888edghqw475cklq.png" alt="enterprise-rag-evaluation" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The evaluation runner calls &lt;code&gt;POST /query&lt;/code&gt; internally for each case, which means it exercises the entire pipeline: authentication, role filter, retrieval, generation, and citation assembly. The metrics reflect actual system behavior, not a mocked retrieval path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the evaluation set structure matters as much as the metrics
&lt;/h2&gt;

&lt;p&gt;The evaluation set (&lt;code&gt;demo/evaluation_set.json&lt;/code&gt;) defines each case with three fields alongside the question and role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expected document IDs&lt;/strong&gt; — documents that must appear in the citation set for the case to pass&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forbidden document IDs&lt;/strong&gt; — documents that must not appear in the citation set&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both lists are required for every case. An evaluation case without forbidden document IDs cannot measure restricted leakage. An evaluation set without any cases involving restricted documents cannot validate access control at all.&lt;/p&gt;

&lt;p&gt;Most RAG golden sets I have reviewed define expected documents only. They measure retrieval recall but provide no signal on access control correctness. Adding forbidden document IDs to every test case involving a restricted document is the minimum viable evaluation standard for an enterprise knowledge system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhagloap7qeb04ci7fxnp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhagloap7qeb04ci7fxnp.png" alt="enterprise-rag-citations" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the evaluation set does not yet cover
&lt;/h2&gt;

&lt;p&gt;The current evaluation set is optimized for access-control validation and citation tracing. It does not yet cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Answer correctness&lt;/strong&gt; — whether the generated answer is factually accurate relative to the cited documents. This requires human relevance labels or LLM-as-judge evaluation templates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic retrieval quality&lt;/strong&gt; — the lexical retriever handles the current evaluation set well. A semantic retrieval configuration may return different ranked results that pass the access-control checks but rank differently by relevance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regression thresholds in CI&lt;/strong&gt; — the evaluation runner is callable from CI. The current setup does not fail a CI run if pass rate drops below a threshold. Adding a threshold check (&lt;code&gt;if restricted_leak_count &amp;gt; 0: fail&lt;/code&gt;) to the CI workflow is the practical next hardening step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are documented roadmap items, not silent gaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Evaluation set size is small — calibrated for repeatable local validation, not production-scale coverage.&lt;/li&gt;
&lt;li&gt;Answer correctness evaluation requires human labels or an LLM judge. Neither is implemented in the current evaluation runner.&lt;/li&gt;
&lt;li&gt;Latency benchmarks reflect the local SQLite retrieval path. Azure AI Search latency profiles will differ.&lt;/li&gt;
&lt;li&gt;The evaluation runner requires the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; when management protection is enabled, which prevents accidental evaluation runs in shared environments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next engineering step
&lt;/h2&gt;

&lt;p&gt;Review the evaluation cases in &lt;code&gt;demo/evaluation_set.json&lt;/code&gt; and count how many include at least one forbidden document ID. If any cases have only expected documents, add a forbidden document ID for a restricted document that should not be retrievable by that role. Then re-run &lt;code&gt;POST /eval/run&lt;/code&gt; and verify the leakage count remains zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question for you
&lt;/h2&gt;

&lt;p&gt;Does your current RAG evaluation set include forbidden document IDs alongside expected document IDs? If not, how do you validate that restricted documents are not influencing answers for unauthorized roles?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next post: Security decisions in Enterprise RAG — how API keys, audit logs, and the order of role enforcement work together to make the system defensible.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
    </item>
    <item>
      <title>Three Design Decisions That Shaped the Enterprise RAG Retrieval Pipeline</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Thu, 21 May 2026 16:36:00 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/three-design-decisions-that-shaped-the-enterprise-rag-retrieval-pipeline-50p2</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/three-design-decisions-that-shaped-the-enterprise-rag-retrieval-pipeline-50p2</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG — A practitioner's build log | Post 3 of 6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A retrieval pipeline has more design surface than it appears. The technology choices — vector search, LLM provider, storage engine — get most of the attention. The structural choices — where filtering happens, how evaluation is wired, what the dashboard connects to — determine whether the system actually works correctly in a production environment.&lt;/p&gt;

&lt;p&gt;This post documents three structural decisions I made in Enterprise RAG, the constraint that drove each one, and the cost I accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: Lexical retrieval before semantic — sequencing, not a permanent choice
&lt;/h2&gt;

&lt;p&gt;The default retrieval implementation uses token cosine similarity against a local SQLite chunk store (&lt;code&gt;RAG_RETRIEVAL_PROVIDER=local&lt;/code&gt;). Not vector embeddings. Not a managed search index. Lexical scoring.&lt;/p&gt;

&lt;p&gt;This was a sequencing decision, not a technology preference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constraint:&lt;/strong&gt; Access control validation requires a deterministic retrieval baseline. If retrieval results vary across runs — because embedding models update, because vector indices are rebuilt, because approximate nearest neighbor algorithms introduce non-determinism — the evaluation set becomes unreliable. A &lt;code&gt;restricted_leak_count&lt;/code&gt; of zero means nothing if retrieval is non-deterministic and the same query might return different chunks tomorrow.&lt;/p&gt;

&lt;p&gt;Lexical retrieval is fully deterministic. Given the same document corpus and the same query, it returns the same ranked chunk list every time. That makes the evaluation set a reliable regression test rather than a probabilistic snapshot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The accepted cost:&lt;/strong&gt; Lexical scoring does not capture semantic similarity. A question about "headcount reduction" will not retrieve a chunk that uses the phrase "workforce restructuring" unless there is token overlap. Semantic retrieval closes that gap — at the cost of determinism in the local validation environment.&lt;/p&gt;

&lt;p&gt;The Azure AI Search adapter (&lt;code&gt;RAG_RETRIEVAL_PROVIDER=azure_ai_search&lt;/code&gt;) is implemented for production use, where semantic and hybrid query modes are available. The retrieval provider is a configuration switch, not a code change. Switching from local to Azure AI Search does not alter the access control layer, the evaluation runner, or the API surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: API-backed dashboard — not direct database access
&lt;/h2&gt;

&lt;p&gt;The Streamlit dashboard (&lt;code&gt;dashboard/app.py&lt;/code&gt;) connects to the FastAPI API layer, not the database directly. Every dashboard operation — querying documents, fetching metrics, running evaluations, reviewing the citation log — goes through an authenticated API call.&lt;/p&gt;

&lt;p&gt;This was not a minor implementation choice. It was a deliberate architectural boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constraint:&lt;/strong&gt; A dashboard that reads the database directly cannot be deployed in a containerized or cloud environment without granting the dashboard container database credentials. That creates a credential distribution problem: every new environment where the dashboard runs needs database access, which widens the credential surface.&lt;/p&gt;

&lt;p&gt;An API-backed dashboard has a single credential requirement: the &lt;code&gt;DASHBOARD_API_URL&lt;/code&gt; and optionally &lt;code&gt;DASHBOARD_ADMIN_TOKEN&lt;/code&gt;. The dashboard container never holds database credentials. It holds only the API location and the management token. The API enforces authorization. The database credentials stay with the API container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The accepted cost:&lt;/strong&gt; Every dashboard operation adds one network hop compared to direct database access. For a local development setup this is negligible. For a cloud-deployed dashboard querying an API on the same virtual network, it is also negligible. The cost is only relevant if the dashboard is running in a significantly different network zone from the API — which would itself be an unusual deployment topology.&lt;/p&gt;

&lt;p&gt;The secondary benefit: the API-backed dashboard tests the public API surface on every dashboard interaction. If the dashboard shows correct data, the API is returning correct data. That is a form of continuous integration that direct database access cannot provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: Evaluation runner as a live API endpoint — not an offline script
&lt;/h2&gt;

&lt;p&gt;The evaluation runner is exposed as &lt;code&gt;POST /eval/run&lt;/code&gt; — a standard API endpoint that runs the evaluation set against the live query pipeline and returns metrics directly.&lt;/p&gt;

&lt;p&gt;Most RAG evaluation setups I have seen are offline scripts: pull a golden set, run retrieval, compare results, write a report. The script does not call the production API. It calls the retrieval components directly, often with mocked or simplified versions of the access control layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constraint:&lt;/strong&gt; If the evaluation script bypasses the access control layer, it cannot detect access control failures. A &lt;code&gt;restricted_leak_count&lt;/code&gt; computed by calling the retriever directly — without going through the role filter — will always be zero, regardless of whether the filter is actually working in production.&lt;/p&gt;

&lt;p&gt;By routing evaluation through &lt;code&gt;POST /eval/run&lt;/code&gt;, which calls &lt;code&gt;POST /query&lt;/code&gt; internally, the evaluation runner tests the entire pipeline: authentication handling, role filter, retrieval, generation, and citation assembly. Every evaluation case exercises the same code path that a real user request exercises.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The accepted cost:&lt;/strong&gt; Live evaluation runs against the production database. In a high-traffic environment, running a large evaluation set could add query load. The mitigation is to run evaluations at low-traffic windows or against a staging environment — not to move evaluation back to a disconnected script.&lt;/p&gt;

&lt;p&gt;The current evaluation set is small and optimized for repeatable access-control checks. Extending it with larger golden sets, human relevance labels, and answer correctness checks is a documented roadmap item.&lt;/p&gt;

&lt;h2&gt;
  
  
  One decision I made explicitly not to make yet
&lt;/h2&gt;

&lt;p&gt;Role metadata is currently embedded in document front matter — each markdown document has a &lt;code&gt;allowed_roles&lt;/code&gt; field that specifies which roles can retrieve it. This is correct for a local deterministic environment where document metadata is under engineering control.&lt;/p&gt;

&lt;p&gt;In production, role context should come from the identity provider — Entra ID claims or OIDC bearer token attributes — not from request body parameters or document-embedded metadata alone. I did not implement full Entra ID role claim integration because it requires a live Azure tenant to validate. The configuration path is documented and the &lt;code&gt;AUTH_PROVIDER=entra&lt;/code&gt; setting is implemented. The end-to-end test of role-from-identity-claim requires a real identity provider.&lt;/p&gt;

&lt;p&gt;That is a known gap. It is in the production considerations section of &lt;code&gt;docs/security.md&lt;/code&gt;, not hidden in implementation comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lexical retrieval does not capture semantic similarity. Queries with no token overlap with document chunks will not retrieve relevant results even when the content is semantically related.&lt;/li&gt;
&lt;li&gt;Evaluation set size is calibrated for local access-control validation. Answer quality evaluation — correctness labels, human relevance ratings — is a planned extension.&lt;/li&gt;
&lt;li&gt;Entra ID role claim integration requires a live Azure tenant for end-to-end validation. The local implementation uses request-body role parameters, which must not be trusted in production without API key authentication.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;POST /eval/run&lt;/code&gt; endpoint requires the &lt;code&gt;ADMIN_TOKEN&lt;/code&gt; when management protection is enabled. Evaluation runs in protected environments require the admin credential.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next engineering step
&lt;/h2&gt;

&lt;p&gt;Add one document to the corpus with &lt;code&gt;allowed_roles: ["finance"]&lt;/code&gt;, run &lt;code&gt;POST /eval/run&lt;/code&gt;, and verify that the new document appears in the blocked count for non-finance evaluation cases. That single test confirms the role filter is reading document metadata correctly and applying it before scoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question for you
&lt;/h2&gt;

&lt;p&gt;Does your internal RAG evaluation pipeline call the same API endpoints that production queries use, or does it call retrieval components directly? If it bypasses the access control layer, does your &lt;code&gt;restricted_leak_count&lt;/code&gt; metric actually measure anything?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next post: The evaluation metrics that matter for enterprise RAG — and why pass rate alone is not enough to validate a system that handles restricted documents.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langchain</category>
      <category>vectordatabase</category>
      <category>rag</category>
    </item>
    <item>
      <title>The Access Control Gap That Makes Most Enterprise RAG Systems Dangerous</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Tue, 19 May 2026 20:19:00 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/the-access-control-gap-that-makes-most-enterprise-rag-systems-dangerous-o0l</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/the-access-control-gap-that-makes-most-enterprise-rag-systems-dangerous-o0l</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG — A practitioner's build log | Post 1 of 6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a retrieval failure mode that does not show up in accuracy benchmarks: a system that finds the right document but returns it to the wrong person.&lt;/p&gt;

&lt;p&gt;Most RAG evaluation frameworks measure whether the retrieved chunks are relevant to the question. Few measure whether those chunks should have been retrievable at all given who asked. In an enterprise context — where the same knowledge base holds HR policy, engineering runbooks, finance forecasts, and security incident reports — that gap is not a minor edge case. It is a fundamental design flaw.&lt;/p&gt;

&lt;p&gt;I built Enterprise RAG specifically to treat access control as a first-class retrieval requirement, not an afterthought applied after the answer is generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with post-retrieval filtering
&lt;/h2&gt;

&lt;p&gt;The naive approach to document access control in a RAG system is to retrieve first and filter second: score all candidate chunks by relevance, then strip out the ones the user is not allowed to see before generating the answer.&lt;/p&gt;

&lt;p&gt;This approach fails in two ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It leaks into the answer.&lt;/strong&gt; A generative model given 20 chunks — including 5 restricted ones — can synthesize information from all 20 even if the restricted chunks are stripped from the citation list before the response is returned. The model has already read the finance forecast before you decided not to show it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It provides false assurance.&lt;/strong&gt; Citation filtering gives the appearance of access control without enforcing it in the part of the pipeline that matters. An audit of the response shows no restricted citations. But the answer content may reflect them.&lt;/p&gt;

&lt;p&gt;The correct architecture applies access control before retrieval scoring. Unauthorized chunks are excluded from the candidate set entirely. They are never ranked, never passed to the generator, and never cited.&lt;/p&gt;

&lt;h2&gt;
  
  
  What silently leaks in a typical internal knowledge base
&lt;/h2&gt;

&lt;p&gt;Consider a company running a single internal knowledge base with documents across four categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HR and operations&lt;/strong&gt; — visible to all employees&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engineering runbooks&lt;/strong&gt; — visible to engineers and above&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finance forecasts and variance reports&lt;/strong&gt; — visible to finance team and executives&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security incident reports&lt;/strong&gt; — visible to engineers and security team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A standard question like "What was the revenue variance in Q3?" asked by an employee role against a system with post-retrieval filtering may return an answer that reflects finance data — even if the finance document does not appear in the citation list. The system retrieved it, scored it, passed it to the generator, and then quietly removed it from the citations.&lt;/p&gt;

&lt;p&gt;That is not a hypothetical. It is the predictable behavior of any system where retrieval and access control are separate pipeline stages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validation test that most teams do not run
&lt;/h2&gt;

&lt;p&gt;Before I built anything, I defined the evaluation test that the system had to pass:&lt;/p&gt;

&lt;p&gt;Ask the same question as two different roles. The answer content and the citation list should differ based on role. If an employee and a finance manager ask "What is the Q3 forecast variance?" and receive answers that contain the same information — regardless of whether the citations differ — the access control is not working.&lt;/p&gt;

&lt;p&gt;The evaluation set in Enterprise RAG includes explicit forbidden document IDs per test case. The &lt;code&gt;restricted_leak_count&lt;/code&gt; metric counts how many evaluation cases returned at least one forbidden document. For a system with correct pre-retrieval access control, that count should be zero.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnj7ib6mtakdq4260cmxc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnj7ib6mtakdq4260cmxc.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshot above shows this test passing: the employee role receives an answer grounded in publicly accessible policy documents, while the finance role receives an answer that additionally cites the restricted finance document. Same question. Different retrieval sets. No leakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes operationally
&lt;/h2&gt;

&lt;p&gt;The operational implication is that RAG deployment in an enterprise knowledge base requires a different validation standard than consumer or internal-tooling RAG.&lt;/p&gt;

&lt;p&gt;Retrieval relevance is not sufficient. You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A role model that maps document access to user identity&lt;/li&gt;
&lt;li&gt;Pre-retrieval filtering enforced before scoring&lt;/li&gt;
&lt;li&gt;An evaluation set that includes forbidden documents per role, not just expected documents&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;restricted_leak_count&lt;/code&gt; metric tracked alongside pass rate and citation coverage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without all four, you cannot know whether your system is leaking restricted content. You can only know whether it is retrieving relevant content — which is a different and less important question in an enterprise security context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The current implementation uses lexical retrieval with token cosine similarity scoring. Semantic or hybrid retrieval is a planned extension. Lexical retrieval is accurate enough for the validation workflow but does not match production semantic search quality.&lt;/li&gt;
&lt;li&gt;Role metadata is embedded in document front matter. Production deployments should derive role context from Entra ID or an OIDC identity provider, not request body parameters.&lt;/li&gt;
&lt;li&gt;The reference documents are synthetic. The evaluation set is calibrated for repeatable local validation, not a production-scale golden set.&lt;/li&gt;
&lt;li&gt;Multi-tenant isolation is a documented production consideration. The current implementation is single-organization.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next engineering step
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;POST /eval/run&lt;/code&gt; against the seeded demo data and check the &lt;code&gt;restricted_leak_count&lt;/code&gt;. If it is zero, access control is enforced. Then modify the retrieval pipeline to apply scoring before filtering and observe what changes in the evaluation output.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question for you
&lt;/h2&gt;

&lt;p&gt;If you queried your internal knowledge base with a restricted finance document in the index today, would your evaluation set detect whether that document's content influenced the answer — or only whether it appeared in the citation list?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next post: The architecture that puts access control before retrieval scoring, and why the order of operations is the entire design.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>How Enterprise RAG Is Structured: Why Access Control Comes Before Retrieval Scoring</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Tue, 19 May 2026 17:44:48 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/how-enterprise-rag-is-structured-why-access-control-comes-before-retrieval-scoring-4hh4</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/how-enterprise-rag-is-structured-why-access-control-comes-before-retrieval-scoring-4hh4</guid>
      <description>&lt;p&gt;&lt;em&gt;Enterprise RAG — A practitioner's build log | Post 2 of 6&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The architecture of a RAG system is determined by one decision above all others: where in the pipeline does access control happen?&lt;/p&gt;

&lt;p&gt;Get that order wrong and the entire system is structurally insecure regardless of how well the retrieval scores or how accurate the generated answers are. Get it right and the security guarantee holds even as the document corpus grows, roles change, and retrieval algorithms are swapped.&lt;/p&gt;

&lt;p&gt;In Enterprise RAG, the order is fixed: role filtering runs before retrieval scoring. That single constraint drives every component boundary in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request flow: the order that matters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;User → POST /query (question + user_role)&lt;br&gt;
           ↓&lt;br&gt;
       Load all candidate document chunks&lt;br&gt;
           ↓&lt;br&gt;
       Apply role filter — unauthorized chunks removed here&lt;br&gt;
           ↓&lt;br&gt;
       Score accessible chunks (token cosine similarity)&lt;br&gt;
           ↓&lt;br&gt;
       Select top citations&lt;br&gt;
           ↓&lt;br&gt;
       Generate answer from cited context only&lt;br&gt;
           ↓&lt;br&gt;
       Persist query metrics and citation log&lt;br&gt;
           ↓&lt;br&gt;
       Return: answer + citations + latency + retrieval metrics&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The role filter sits between loading candidates and scoring them. The generator never receives an unauthorized chunk. The citation list cannot include what the generator never saw. The audit log records exactly which chunks were retrieved, which were filtered, and how many were blocked by role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Component breakdown
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;FastAPI query API (&lt;code&gt;enterprise_rag/api.py&lt;/code&gt;).&lt;/strong&gt; Receives authenticated requests on &lt;code&gt;POST /query&lt;/code&gt;. Derives the retrieval role from the &lt;code&gt;X-API-Key&lt;/code&gt; header when present — key-holder role overrides any role supplied in the request body, preventing role elevation by callers. Falls back to request body role for unauthenticated queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Role-based candidate filter.&lt;/strong&gt; Loads document chunks from SQLite, then filters by the &lt;code&gt;allowed_roles&lt;/code&gt; metadata field on each chunk. Accepted values include &lt;code&gt;all&lt;/code&gt;, &lt;code&gt;engineer&lt;/code&gt;, &lt;code&gt;finance&lt;/code&gt;, and &lt;code&gt;admin&lt;/code&gt;. A chunk with &lt;code&gt;allowed_roles: ["finance", "admin"]&lt;/code&gt; is excluded from engineer and employee queries before a single relevance score is computed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lexical retriever.&lt;/strong&gt; Scores the filtered candidate set using token cosine similarity. Because filtering happened upstream, the scorer operates only on chunks the requesting role is authorized to see. Retrieval quality metrics — retrieved chunk count, top retrieval score, RBAC-blocked chunk count — are captured per query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mock answer generator.&lt;/strong&gt; Builds a deterministic answer from the top cited chunks. In &lt;code&gt;LLM_PROVIDER=mock&lt;/code&gt; mode this runs without any provider key, making local validation fully reproducible. OpenAI and Azure OpenAI adapters are configuration-selectable for production use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query log and metrics store (SQLite).&lt;/strong&gt; Every query persists: question, role, citations, latency, retrieved chunk count, and RBAC-blocked chunk count. This log is the audit record. It answers not just "what did the system return?" but "what was blocked and why?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evaluation runner (&lt;code&gt;POST /eval/run&lt;/code&gt;).&lt;/strong&gt; Runs the evaluation set against the live query pipeline. Reports pass rate, restricted leakage count, citation coverage, and average latency. Because the evaluation runner calls the same &lt;code&gt;/query&lt;/code&gt; endpoint as a real user, it tests the entire pipeline end-to-end — not a mocked retrieval path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API-backed Streamlit dashboard.&lt;/strong&gt; The dashboard calls the FastAPI layer rather than reading the database directly. This is a deliberate design choice: the same API boundary used for the UI can be retained for containerized or Azure deployment without changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwshugsrejvfcr8ru6dx2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwshugsrejvfcr8ru6dx2.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Azure AI Search fits the same pipeline
&lt;/h2&gt;

&lt;p&gt;The local retrieval implementation uses lexical scoring against SQLite chunks. The Azure AI Search adapter replaces the retriever component while keeping the same access control boundary:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Azure AI Search filter (before results are returned):&lt;br&gt;
allowed_roles/any(role: role eq 'all' or role eq '&amp;lt;user_role&amp;gt;')&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The filter is applied server-side at the search index before results are returned to the application. The application layer role filter provides defense in depth, but the primary enforcement happens at the index level when Azure AI Search is the retrieval provider.&lt;/p&gt;

&lt;p&gt;This is the correct architecture for a production deployment: access control enforced at two layers — index filter and application filter — so a misconfiguration at one layer does not compromise the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The local-to-Azure configuration switch
&lt;/h2&gt;

&lt;p&gt;Every component in the local architecture has a direct Azure counterpart:&lt;/p&gt;

&lt;p&gt;| Local | Azure |&lt;br&gt;
|||&lt;br&gt;
| SQLite metadata and chunks | Azure PostgreSQL or Cosmos DB |&lt;br&gt;
| Local markdown files | Azure Blob Storage |&lt;br&gt;
| Lexical retriever | Azure AI Search |&lt;br&gt;
| Mock answer generator | Azure OpenAI |&lt;br&gt;
| Local API and dashboard | Azure Container Apps |&lt;br&gt;
| Environment variables | Azure Key Vault |&lt;br&gt;
| &lt;code&gt;print&lt;/code&gt; / file logs | Application Insights |&lt;br&gt;
| Local users and hashed keys | Microsoft Entra ID |&lt;/p&gt;

&lt;p&gt;Switching between local and Azure requires only environment variable changes. No code path changes, no schema migrations between local and PostgreSQL — SQLAlchemy handles both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The local retriever uses lexical scoring. Semantic similarity and hybrid retrieval are planned Azure AI Search extensions. Lexical scoring is sufficient for deterministic local validation but will not match embedding-based relevance in production.&lt;/li&gt;
&lt;li&gt;The dashboard is single-instance. Distributed session state and multi-instance deployments require additional coordination.&lt;/li&gt;
&lt;li&gt;Rate limiting is in-memory per instance. Multi-instance production deployments require Redis-backed or API gateway rate limiting.&lt;/li&gt;
&lt;li&gt;Tenant isolation for multi-organization deployments is a documented production consideration, not yet implemented.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next engineering step
&lt;/h2&gt;

&lt;p&gt;Query the system as &lt;code&gt;employee&lt;/code&gt; role for a question that has a known restricted finance document in scope. Inspect the &lt;code&gt;rbac_blocked_count&lt;/code&gt; field in the query log. Confirm that the blocked count is non-zero — meaning the filter ran and excluded chunks — before the answer was generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question for you
&lt;/h2&gt;

&lt;p&gt;In your current RAG architecture, at what stage does access control run — before chunk scoring, after chunk scoring, or only at the citation display layer? Do you have a metric that tracks how many chunks were filtered per query?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next post: Three design decisions that shaped the retrieval pipeline — why lexical retrieval before semantic, why API-backed dashboard over direct database access, and why evaluation is built into the API rather than run as a separate offline script.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>nlp</category>
    </item>
    <item>
      <title>Building Production-Grade Human-in-the-Loop Workflow Automation with LangGraph</title>
      <dc:creator>Manjunath</dc:creator>
      <pubDate>Mon, 18 May 2026 19:53:23 +0000</pubDate>
      <link>https://forem.com/manjunath_d35c391da339e5b/building-production-grade-human-in-the-loop-workflow-automation-with-langgraph-1b6d</link>
      <guid>https://forem.com/manjunath_d35c391da339e5b/building-production-grade-human-in-the-loop-workflow-automation-with-langgraph-1b6d</guid>
      <description>&lt;p&gt;The Problem With Enterprise Approval Workflows.&lt;/p&gt;

&lt;p&gt;Most enterprise approval workflows are not systems. They are sequences of emails.&lt;/p&gt;

&lt;p&gt;A compliance review is filed. Someone forwards it to a reviewer. The reviewer replies. A manager is CC'd. Someone updates a spreadsheet. Three days later, the spreadsheet has a new column that no one agreed to add.&lt;/p&gt;

&lt;p&gt;When something goes wrong - a decision is disputed, an auditor asks questions, a regulator wants a decision log - the answer is in someone's inbox. If the reviewer has left the company, the answer may not be recoverable at all.&lt;/p&gt;

&lt;p&gt;The pattern breaks down further when workflows cross systems. A procurement approval might require a vendor check, a budget validation, a legal review, and a final sign-off. Each step is handled by a different team, in a different system, with no shared state. When step three fails, starting over means starting from step one.&lt;/p&gt;

&lt;p&gt;The technical problem is the absence of persistent, structured workflow state. A workflow that lives in email has no state. It can't be paused and resumed. It can't be audited. It can't be recovered if a step fails.&lt;br&gt;
This post covers how I built a platform to solve this using LangGraph, FastAPI, and SQLite - with a production path to Azure.&lt;br&gt;
Why LangGraph&lt;br&gt;
The core requirement was a workflow engine that could pause at a human decision point and resume from that exact position - surviving server restarts between the pause and the resume.&lt;br&gt;
LangGraph's &lt;code&gt;StateGraph&lt;/code&gt; is well-suited to this because it separates the workflow structure from the workflow state. The graph is a set of nodes (agent functions) and edges (routing logic). The state is a typed dictionary that flows through the graph. Checkpointing saves the state at each transition.&lt;/p&gt;

&lt;p&gt;Two specific LangGraph primitives made this practical:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;interrupt_before&lt;/code&gt;&lt;/strong&gt;: The graph can be compiled with a list of node names that should trigger an interrupt before execution. When the graph reaches one of those nodes, it halts, persists the current state to the checkpointer, and returns control to the caller. The graph resumes when explicitly invoked again with the same thread ID.&lt;br&gt;
&lt;strong&gt;&lt;code&gt;AsyncSqliteSaver&lt;/code&gt;&lt;/strong&gt;: A persistent checkpoint backend that writes graph state to SQLite. Unlike the default &lt;code&gt;MemorySaver&lt;/code&gt;, which is process-local, &lt;code&gt;AsyncSqliteSaver&lt;/code&gt; persists across server restarts. The same checkpoint file is readable by any process with the correct connection string.&lt;br&gt;
These two primitives are the foundation of the human-in-the-loop pattern described in the next section.&lt;br&gt;
The Checkpoint Pattern&lt;br&gt;
The most common mistake in stateful workflow systems is assuming process memory is durable.&lt;br&gt;
If the workflow is running inside a long-lived process, and that process restarts, the workflow state is gone. In practice, this means every server restart, every deployment, and every crash silently kills every in-flight workflow.&lt;br&gt;
The fix is to write state to a persistent store at every transition, not just at the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.checkpoint.aiosqlite&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSqliteSaver&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;AsyncSqliteSaver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_conn_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHECKPOINT_DB_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;workflow_module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_graph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configurable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;}})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every call to &lt;code&gt;ainvoke&lt;/code&gt; with the same &lt;code&gt;thread_id&lt;/code&gt; resumes from the last persisted checkpoint. If the server restarts between the risk scoring step and the human review step, the next invocation picks up from risk scoring output - not from the beginning.&lt;br&gt;
In production, &lt;code&gt;CHECKPOINT_DB_URL&lt;/code&gt; is a Postgres connection string. The application code does not change.&lt;br&gt;
The Human Pause: Interrupt vs Polling&lt;br&gt;
The conventional approach to human-in-the-loop is a polling loop: an agent writes a "pending review" flag to a database, and a background process polls until a human updates the flag.&lt;br&gt;
This has two failure modes. First, the polling process itself is a point of failure - if it crashes, the workflow never resumes. Second, concurrent reviewers can both see "pending" and submit conflicting decisions before either decision is reflected.&lt;br&gt;
The interrupt approach eliminates both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;interrupt_before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the graph reaches &lt;code&gt;decision_agent&lt;/code&gt;, it halts. The caller receives control. The workflow state is in the checkpoint store. No polling. No flags. No background process.&lt;br&gt;
Resume happens via a single API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Human submits decision via POST /api/workflows/{id}/decide
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aupdate_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configurable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;human_decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision_notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ainvoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configurable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;}})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The graph loads the checkpoint, applies the updated state, and continues from &lt;code&gt;decision_agent&lt;/code&gt;. The reviewer's decision, identity, and timestamp are written to the audit trail before the graph resumes.&lt;br&gt;
 Immutable Audit Trails&lt;br&gt;
An audit trail that can be modified after the fact is not an audit trail.&lt;br&gt;
Every event in this platform is appended to a log. No update operations. No delete operations. The audit logger exposes a single method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;audit_logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_scoring&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SYSTEM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RISK_SCORE_COMPUTED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning_summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Three rule failures in financial controls section&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data&lt;/code&gt; field is intentionally sanitized before logging. Document content - extracted text, raw field values, personal data - is never written to the audit trail. The log records what the system did (risk score computed, rule evaluated, human decision submitted) and the structured metadata that supports that record. Not the raw content that was processed.&lt;br&gt;
This matters when the audit trail is itself subject to data retention requirements. A log that contains full document text is subject to the same retention and access controls as the document. A log that contains metadata is not.&lt;br&gt;
Pluggable Workflow Registry&lt;br&gt;
The architecture has a single orchestration engine and multiple workflow modules. Adding a new workflow requires one new folder in &lt;code&gt;workflows/&lt;/code&gt;, implementing a standard interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkflowModule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;span class="err"&gt; &lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_graph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt; …&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_input_schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="err"&gt; …&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The registry discovers and loads modules at startup. The API, the dashboard, and the audit trail require no changes when a new workflow is added.&lt;br&gt;
The platform currently ships with two modules: compliance review and procurement. Both were added without modifying the orchestration engine. The third module - whatever it is - will be added the same way.&lt;br&gt;
What This Enables&lt;br&gt;
The compliance review workflow demonstrates the pattern at its most structured. Six automated stages produce a risk score and a rule evaluation before a human reviewer sees the workflow. The reviewer sees the complete automated analysis - not a summary, the full output - and submits a decision. The workflow generates a compliance certificate or a rejection report. The audit trail records every stage from document intake to certificate generation.&lt;br&gt;
The same pattern applies to any workflow where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple sequential steps process the same input&lt;/li&gt;
&lt;li&gt;A human decision is required at a defined checkpoint&lt;/li&gt;
&lt;li&gt;The decision and its context must be traceable after the fact
Vendor onboarding, contract review, budget approval, incident escalatio all of these map cleanly to the same architecture.
The platform is local-first, with a documented path to Azure: SQLite to Postgres, local file storage to Blob Storage, API keys to Key Vault, uvicorn to Container Apps. One environment variable change per component.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The technical foundation for reliable enterprise workflow automation is not complicated. Persistent state, genuine human-in-the-loop interrupts, and an immutable audit log cover the majority of the requirements in regulated industries.&lt;br&gt;
The difficulty is in the details: checkpoints that survive restarts, interrupt/resume that doesn't require polling, audit logs that capture decisions without capturing personal data.&lt;br&gt;
The full platform, including architecture diagrams, state machine documentation, a working demo, and 56 passing tests, is at:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/manjunath-hanmantgad/multi-agent-orchestration" rel="noopener noreferrer"&gt;https://github.com/manjunath-hanmantgad/multi-agent-orchestration&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with LangGraph, FastAPI, SQLite (Postgres-ready), and Tailwind CSS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>python</category>
    </item>
  </channel>
</rss>
