Content OS

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:

subjectJudgesQuestion
"source"A Discovery feed itemWould writing content based on this article create risk?
"draft"A finished blog draftThis 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

ConcernFile
The agentapp/src/lib/feed-compliance-agent.ts
Rulebook (Intelligence Center)app/src/lib/compliance-intelligence.ts
Learned exceptionsapp/src/lib/compliance-exceptions.ts
Learning path (dismissal → exception)app/src/services/blogs/blog-compliance-learning.ts
Deterministic blocked-phrase scanapp/src/lib/compliance.ts
Draft scan wrapperapp/src/services/blogs/blog-compliance.ts
Draft agent wrapperapp/src/services/blogs/blog-compliance-agent.ts
Generation-time pipelineapp/src/services/compliance/pipeline.ts, scanners.ts
CMS gate (pure, tested)app/src/lib/blog-compliance-gate.ts
Feed-side runnerapp/src/lib/feed.tsrunComplianceAgentForItem()
Review UI (shared)app/src/components/modules/compliance-review-sheet.tsx
Rulebook editorapp/src/components/modules/compliance-intelligence-module.tsx
Exceptions editorapp/src/components/modules/compliance-exceptions-module.tsx
In-chat rewrite reviewapp/src/components/modules/blog-refine-proposal.tsx
Block-level body delta (pure, tested)app/src/lib/blog-body-diff.ts
Review queueapp/src/components/modules/compliance-review-module.tsx

Routes

  • POST /api/tenants/[tenantId]/feed/items/[itemId]/compliance — run on a source
  • GET same path — read the stored verdict
  • POST /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; returns changed_blocks, writes nothing
  • PUT /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:

  1. 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.
  2. The generation-time pipeline (services/compliance/pipeline.ts) scores drafts during generation and can retry with feedback, bounded by getComplianceMaxAttempts() / 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

SeverityMeaning
highMust not proceed without sign-off — blocks Send to CMS
mediumMay proceed with edits
lowWorth 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_ids alone can run to 40 entries before a single finding).
  • Usage tracked under feature compliance, operation feed_review or blog_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 evidence is marked inline in the TipTap body as a view-only ProseMirror decoration. It never enters getHTML(), 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.
  • reviewedVersion vs draft.version is 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 RBI gate 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) and seenCount.
  • 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.
Source: roadmap/marketing-os/agents/compliance-agent/technical.md