Compliance Agent — Technical Reference
The only agent with a veto. Reads against a tenant-authored rulebook, cites the rule it fired on, and gates delivery to the CMS.
Audience: engineers. Non-technical version: overview.md. Back to the agents index.
What it does
Judges a piece of text against a regulated Indian financial-services rulebook and returns findings — each with a severity, an evidence phrase, and a citation to the rule that produced it.
It runs at two points in the pipeline with different framing:
subject | Judges | Question |
|---|---|---|
"source" | A Discovery feed item | Would writing content based on this article create risk? |
"draft" | A finished blog draft | This is about to publish under the brand's own name. Is every claim defensible? |
Same code path, different prompt framing. The distinction matters: reporting a rate is fine, promising one is not — and a journalist's verified report becomes the brand's own claim the moment it is republished.
Where it lives
| Concern | File |
|---|---|
| The agent | app/src/lib/feed-compliance-agent.ts |
| Rulebook (Intelligence Center) | app/src/lib/compliance-intelligence.ts |
| Learned exceptions | app/src/lib/compliance-exceptions.ts |
| Learning path (dismissal → exception) | app/src/services/blogs/blog-compliance-learning.ts |
| Deterministic blocked-phrase scan | app/src/lib/compliance.ts |
| Draft scan wrapper | app/src/services/blogs/blog-compliance.ts |
| Draft agent wrapper | app/src/services/blogs/blog-compliance-agent.ts |
| Generation-time pipeline | app/src/services/compliance/pipeline.ts, scanners.ts |
| CMS gate (pure, tested) | app/src/lib/blog-compliance-gate.ts |
| Feed-side runner | app/src/lib/feed.ts → runComplianceAgentForItem() |
| Review UI (shared) | app/src/components/modules/compliance-review-sheet.tsx |
| Rulebook editor | app/src/components/modules/compliance-intelligence-module.tsx |
| Exceptions editor | app/src/components/modules/compliance-exceptions-module.tsx |
| In-chat rewrite review | app/src/components/modules/blog-refine-proposal.tsx |
| Block-level body delta (pure, tested) | app/src/lib/blog-body-diff.ts |
| Review queue | app/src/components/modules/compliance-review-module.tsx |
Routes
POST /api/tenants/[tenantId]/feed/items/[itemId]/compliance— run on a sourceGETsame path — read the stored verdictPOST /api/tenants/[tenantId]/blogs/drafts/[draftId]/compliance— run on a draft, acknowledge findings, replace the dismissed list, or record an AI fix the reviewer accepted ({resolved})POST /api/tenants/[tenantId]/blogs/drafts/[draftId]/refine— propose a rewrite for one finding; returnschanged_blocks, writes nothingPUT /api/tenants/[tenantId]/compliance-intelligence— edit the rulebook (admin only)PUT /api/tenants/[tenantId]/compliance-exceptions— arm/edit learned exceptions (admin only)GET /api/tenants/[tenantId]/compliance-reviews— pending acknowledgements
Trigger
On demand only — a human presses a button. Generation deliberately stays one LLM call; the agent is not folded into it.
- Discovery card → "Run AI compliance agent"
- Blog studio → "Compliance"
Two things do run automatically, and they are not the agent:
- The deterministic blocked-phrase scan (
lib/compliance.ts) runs on every feed read and every draft write. A rule edit takes effect immediately, with no re-run needed. - The generation-time pipeline (
services/compliance/pipeline.ts) scores drafts during generation and can retry with feedback, bounded bygetComplianceMaxAttempts()/getComplianceMinScore().
Input
- Text:
title,summary, first 5 000 chars of content (same budget as enrichment — a claim appearing only past 5 000 chars is rare enough not to pay for twice). intelligence_rules— the tenant's authored rulebook, each rule carrying its own reasoning and cited source (max 40 rules).restricted_topics(first 60),approved_reasons_to_believe(first 30),brand_avoid(first 1 500 chars).
Output — ComplianceAgentVerdict
{
"findings": [{
"rule_id": "id from intelligence_rules, or empty",
"code": "restricted_claim | guaranteed_return | unverified_claim | competitor_promotion | regulatory_risk | brand_avoid | risky_angle",
"severity": "low | medium | high",
"message": "one sentence telling the writer what to check or change",
"evidence": "the exact phrase, verbatim, so it can be highlighted"
}],
"cleared_rule_ids": ["ids checked and found clean"],
"reason": "one-sentence summary"
}
Version pinned as compliance-agent-v1.
Citations are resolved, not trusted. attachRuleCitations() matches each
rule_id against the stored rulebook — an invented id is dropped rather than
rendered. A model cannot conjure a rule that does not exist.
Deterministic wins ties. mergeComplianceFindings() keeps the exact
blocked-phrase match over an agent paraphrase citing the same evidence, then
sorts high → medium → low.
Severity contract
| Severity | Meaning |
|---|---|
high | Must not proceed without sign-off — blocks Send to CMS |
medium | May proceed with edits |
low | Worth noting only |
Model & prompt
- OpenAI only,
generateOpenAiText,responseMimeType: "application/json". maxOutputTokens: 2500— raised from 900, which truncated on long articles once a tenant had ~20 authored rules (cleared_rule_idsalone can run to 40 entries before a single finding).- Usage tracked under feature
compliance, operationfeed_revieworblog_review.
The prompt is explicitly tuned against over-flagging:
Report only what a reviewer would actually stop. Do NOT flag an article merely for being about finance, for naming a company neutrally, or for reporting facts that a journalist verified. When the article is clean, return an empty findings array — an empty result is the correct and common answer.
Rules must be applied for their stated purpose, not by keyword resemblance to their title.
What happens to a verdict
On a blog draft — persisted as compliance (BlogComplianceReport):
- Each finding's
evidenceis marked inline in the TipTap body as a view-only ProseMirror decoration. It never entersgetHTML(), so it cannot leak into the published body. - Hovering a mark opens a Radix popover with severity, code, message, cited rule, and the actions Ask AI to fix, Full review and Not an issue — plus Apply previous ruling first, when a learned exception covers the finding.
reviewedVersionvsdraft.versionis the staleness signal — an edit bumps the version, marking the report stale and dropping the marks.- Regenerating
$unsets the verdict entirely.
Dismissals are keyed code|evidence via complianceFindingKey() and stored
as compliance_dismissed (whole-list replace, so dismiss and undo are one
endpoint). A dismissal drops the inline mark, counts as cleared by the CMS gate,
and survives an agent re-run — the reviewer already judged that phrase. A
regenerate clears them.
Ask AI to fix — reviewed in the chat
The click posts the finding structurally to the refine route — evidence,
message, and the cited rule's title and rationale — rather than folding a
paraphrase of all four into prose. The model is told to fix the cause the rule
names and that the flagged phrase must not survive verbatim.
The route returns the whole revised body (the right contract for a model) and
changed_blocks (the right one for a reviewer): a block-level Before/After from
blogBodyChangedBlocks(), which splits both bodies on top-level elements and
aligns them with an LCS so one inserted paragraph does not cascade every later
block into "changed". Computed server-side — the diff needs an HTML parser, and
cheerio was already a dependency.
The proposal renders inside the assistant chat bubble with Accept/Reject on the card, the way the script studio reviews a refinement. The full Current vs Proposed comparison is still available, now as an opt-in View full body. Superseded proposals stay in the chat read-only and stamped. A proposal whose base no longer matches the editor is marked stale and cannot be accepted — accepting replaces the whole body, which would silently discard the manual edit.
Resolutions — how a fix clears a finding
Accepting a fix that came from a finding records {key, evidence} in
compliance_resolved. The gate honours a resolution only while that evidence
phrase is absent from the body:
blogComplianceGate(draft, bodyText?) // no bodyText ⇒ resolutions ignored
This is self-correcting and needs no version bookkeeping: undo the fix and the
phrase is back, so the finding is back. It also never takes the model's word that
it fixed something — the phrase has to actually be gone. Omitting bodyText
fails closed. A regenerate clears the list.
The CMS gate
lib/blog-compliance-gate.ts is pure and unit-tested. It blocks Send to CMS
while any high-severity finding is unacknowledged — or, on pre-agent drafts, any
Compliance:-prefixed content_warnings entry.
Enforced in the submit route with a 409, not just in the UI. A reviewer clears it with "Acknowledge and allow sending", stamped with the draft version so a later edit re-gates delivery.
The verdict is refreshed at the moment it matters. If the report is stale
when someone presses Send, the submit route re-runs the agent once — through the
same compliance-agent rate limiter the button uses — and re-evaluates against
the fresh verdict. Editing stays free; delivery is checked against the text
actually being sent. Being rate-limited, or the re-check failing, both fail
closed with a 409: a verdict that cannot be refreshed is one that cannot be
trusted.
Sending to the CMS is always a human action. The gate does not automate the decision — it only guarantees a human read the findings first.
Failure mode
Fails open — deliberately, and it is not the same as "clean".
reviewFeedItemCompliance() returns null when there is nothing to run against
(no OpenAI configured, or no rulebook at all). Callers treat null as
"deterministic scan only", never as "passed". The keyword scan still
stands, so the feed is never blocked by a missing API key.
It throws when the call itself failed, so the on-demand button shows the real reason rather than telling a reviewer to add rules they already added. Enrichment catches that throw and degrades to the keyword scan.
Rulebook — the Intelligence Center
Stored per tenant as complianceIntelligence, edited at
/tenants/[id]/compliance-intelligence (admin-only PUT).
Each rule carries its own reasoning and cited source, so the agent can explain why a rule exists rather than just that it matched. Cap: 40 rules.
Seed IIFL's 22-rule book:
npm run seed:iifl-compliance-intel -- <tenantId>
Full replace, idempotent; source data in
app/scripts/data/iifl/compliance-intelligence.json.
Learned exceptions — what the agent is taught
A dismissal is a judgement, but as stored it is one literal string on one draft. The same ruling gets re-argued next week on text that words it differently. The worked example: a reviewer defines MSME once at the top of a draft, the agent flags every later bare use, and the reviewer dismisses it — then does the same on the next draft, and the next.
learnComplianceException() records the general form once.
interface ComplianceException {
id: string;
ruleId: string; // the Intelligence Center rule this carves out of ("" = by code alone)
code: string;
exception: string; // "An acronym already expanded earlier in the draft need not be expanded again."
rationale: string;
terms: string[]; // lowercased anchors that must appear in a finding's evidence
origin: { draftId; findingKey; userId; at };
seenCount: number;
lastSeenAt: string;
enabled: boolean; // OFF by default — annotates the UI, does not reach the prompt
}
Three properties this holds to.
It never writes the Intelligence Center. Exceptions are a separate tenant array
(complianceExceptions), capped at 40, with their own route and their own page.
The authored rulebook is sourced to a regulation or a client document; nothing in
the learning path can edit it. That separation is what makes a wrong exception
safe to delete.
Nothing is ever silently suppressed. An armed exception is rendered into the
prompt as known_accepted_patterns with an explicit instruction: still report
the finding, never omit one, never lower its severity — set matches_exception
and say so in the message. What changes is what the reviewer is shown, not what
the agent reports.
Learned adjustments are proposals. A learned exception is written unarmed.
Unarmed, it annotates the reviewer's screen immediately: a Previously accepted
badge, the one-liner, the accepted count, and a one-click Apply previous ruling.
Only after a human arms it on /tenants/[id]/compliance-exceptions does it reach
the agent's prompt — which is a change to how every future draft is reviewed.
Matching is deterministic, never asked of the model: same code, same cited
rule (or unscoped), and at least one term present in the finding's evidence,
with padded whole-phrase matching so gold cannot match goldfish. A reviewer
can therefore see exactly why a badge appeared. It runs client-side as well as
server-side, so an exception learned since the last agent run annotates without
waiting for a re-run. The model's own matches_exception claim is a fallback only,
and an invented id is dropped — the same rule as an invented rule citation:
citations are resolved, not trusted.
Cost and failure. A repeat dismissal of a known pattern only bumps
seenCount — no model call. Only a genuinely new pattern costs one small JSON
call. The whole path fails open: no OpenAI key, a bad response, or a full
list all leave the dismissal successful and simply teach nothing. Learning is a
side effect of the click, never a condition of it.
Known gaps
- Blog drafts and feed items only. Scripts and hooks get the deterministic
scan via
evaluateScriptContent/evaluateSocialContent, but no AI agent review. - No
RBIgate as drawn. Page 4 of the roadmap puts an RBI checkpoint at three points in the creative factory (content → creation → approval). Today there is one gate, at the end, on blogs only. - Keyboard access is second-class. Inline hover cards are pointer-driven by design; keyboard users reach every finding only through the sheet.
- 5 000-char budget — same blind spot as the discovery agent.
- No audit trail of who acknowledged what, beyond the version stamp.
Partially closed for dismissals: a learned exception carries
origin(draft, finding key, user, timestamp) andseenCount. - Exceptions are learned from blog drafts only. The store is tenant-level and the agent reads it on both paths, so a Discovery finding is annotated by an exception — but nothing on the Discovery side creates one, because Discovery has acknowledgements rather than per-finding dismissals.
- An exception is only as narrow as the model made it. The prompt asks for one step of generalisation and rejects rulings it cannot state as a checkable condition, but the arming step is the real guard — read it before you arm it.