Supervisor + Critic¶
A privacy analyst gathers evidence notes, an author drafts a data-exposure report, and a skeptical reviewer either approves or sends it back for revision. The loop caps at two revisions to bound runtime.
The point is the last step, not the loop: a report that reads well is
not the same as a report that is grounded. Before anything ships, the
reviewer runs the drafted finding through ground_finding — the GSAR
grounding gate from tulip.security. A finding is emitted only when its
evidence partition clears the proceed threshold; otherwise the call
returns an Abstention and nothing reaches the privacy queue. An
unproven PII-exposure claim is a false positive by construction and
never ships.
This notebook covers:
- Control flow as a
StateGraphwith conditional edges — no hand-rolledwhile True. - Each role is its own
Agentwith a role-specific system prompt. Roles communicate only through state keys (notes,draft,revision_request). - The reviewer node where prose review meets mechanical grounding:
ground_finding(...)scores the evidencePartitionof typedClaims and returns anEvidenceor anAbstention;is_finding(...)narrows the union. stream(mode=StreamMode.NODES)emits one event per node completion for live UI updates.execute(...)returns the authoritative final state plus aGraphResultwith timing and iteration metrics.
The seeded scenario: DLP scan DLP-4471 flags an unmasked email
column in the analytics customer_export view. The grounded claims
trace to the DLP scan rows and the data-lineage graph; the lone
unproven claim — that the records were actually accessed by an
unauthorized third party — has no backing evidence, so the grounding
gate keeps it out of the shipped finding. The drafted finding is tagged
with OwaspLLM.SENSITIVE_INFORMATION_DISCLOSURE and
AtlasTechnique.EXFILTRATION_VIA_AGENT_TOOL so the artifact is portable
into a privacy register or DPIA.
Prerequisites¶
- Basic graph.
- Agent handoff, for an alternative shape.
- GSAR grounded findings, for the grounding primitive in depth.
Run¶
The default provider is the bundled mock model. Set
TULIP_MODEL_PROVIDER (openai / anthropic) and credentials to use a
live model. Set TULIP_MODEL_PROVIDER=mock for offline runs — the
grounding gate is deterministic and exercises the same admit/abstain
path either way.
Source¶
#!/usr/bin/env python3
# Copyright 2026 Tulip Labs
# SPDX-License-Identifier: Apache-2.0
"""Notebook 31: PII-exposure report vs skeptical reviewer — kill unproven claims.
An analyst gathers evidence, an author drafts a data-exposure report, and
a skeptical reviewer (MIRROR — the privacy team's adversarial review role)
either signs off or sends it back because a claim isn't backed by evidence.
The revise loop caps at two passes to bound runtime.
The point of the notebook is the *last* step, not the loop: a report that
reads well is not the same as a report that is grounded. Before anything
ships, the reviewer runs the drafted finding through ``ground_finding`` —
the GSAR grounding gate from ``tulip.security``. A finding is emitted only
when its evidence partition clears the proceed threshold; otherwise the
call returns an ``Abstention`` and nothing reaches the privacy queue. An
unproven exposure claim is a false positive *by construction* and never ships.
- The control flow is a ``StateGraph`` with conditional edges — no
hand-rolled ``while True`` plus message passing.
- Each role is its own ``Agent`` with a role-specific system prompt.
No agent can see the others' internal state; they communicate only
through graph state keys (``notes``, ``draft``, ``revision_request``).
- The reviewer node is where prose review meets mechanical grounding:
``ground_finding(...)`` scores the evidence partition and returns a
``Evidence`` or an ``Abstention``. ``is_finding(...)`` narrows the union.
- ``stream(mode=StreamMode.NODES)`` emits one event per node completion,
so a UI can show "Evidence gathered / Author drafting / Reviewer
adjudicating…" with no extra code.
- ``execute(...)`` returns the authoritative final state plus a
``GraphResult`` with timing and iteration metrics.
```text
START → gather → draft → review → END (ship grounded Evidence | abstain)
↑ │
└── revise (cap: 2)
Run it: .venv/bin/python examples/notebook_31_supervisor_critic_loop.py
The default provider is the bundled mock model. Set TULIP_MODEL_PROVIDER=openai
(or anthropic) and the matching credentials to use a live model. Set
TULIP_MODEL_PROVIDER=mock for offline runs — the grounding gate is
deterministic and exercises the same admit/abstain path either way.
Prerequisites: - Notebook 16 (basic graph). - Notebook 25 (agent handoff) for an alternative shape. - Notebook 37 (GSAR grounded findings) for the grounding primitive in depth. """
from future import annotations
import asyncio from typing import Any
from config import get_model
from tulip.agent import Agent, AgentConfig from tulip.core.events import TerminateEvent from tulip.multiagent.graph import END, START, StateGraph from tulip.reasoning.gsar import Claim, EvidenceType, Partition from tulip.security import ( AtlasTechnique, Indicator, IndicatorType, OwaspLLM, Severity, ground_finding, is_finding, )
---------------------------------------------------------------------------¶
Each role is a real Agent with a role-specific system prompt¶
---------------------------------------------------------------------------¶
def _make_agent(role: str, system_prompt: str, model: Any, max_iterations: int = 2) -> Agent: return Agent( config=AgentConfig( agent_id=f"agent-{role}", model=model, system_prompt=system_prompt, max_iterations=max_iterations, max_tokens=400, ) )
SUPERVISOR_PROMPT = ( "You are the privacy-review team lead. Given the suspected PII exposure " "and the current state, decide whether the Analyst, Author, or Reviewer " "should run next. Respond with ONE word: gather, draft, or review." )
ANALYST_PROMPT = ( "You are a data-privacy analyst. Given a suspected PII exposure, return " "3-5 concise evidence notes drawn from DLP scan output and data-lineage " "review. No speculation. Bullet points only." )
AUTHOR_PROMPT = ( "You are a privacy-incident report author. Given evidence notes (and " "optionally a reviewer's revision request), produce a concise 1-2 " "paragraph report. State only what the evidence supports. Plain prose." )
MIRROR is the privacy team's skeptical / adversarial review role: it¶
reflects the author's claims back against the evidence and refuses¶
anything that can't be traced to it.¶
REVIEWER_PROMPT = (
"You are a skeptical privacy reviewer. Read the draft report and kill "
"unproven claims: every stated impact must trace to the evidence notes. "
"If the report is defensible, respond with exactly: APPROVE. "
"If not, respond with: REVISE:
---------------------------------------------------------------------------¶
The evidence partition — what the reviewer's grounding gate scores.¶
¶
In a live run the analyst's notes would be parsed into typed claims; here¶
the partition is built deterministically from the seeded DLP scan facts so¶
the grounding gate exercises the same admit/abstain path under the mock¶
model. Each claim carries the provenance label GSAR weights against:¶
a scanner/tool row outranks inference, which outranks domain priors.¶
---------------------------------------------------------------------------¶
def _evidence_partition(state: dict[str, Any]) -> Partition: """Build the GSAR partition for the drafted finding.
The grounded claims trace to DLP scan rows and the data-lineage graph;
the lone unproven claim ("records were accessed by a third party") sits
in ``ungrounded`` until access logs confirm it.
"""
return Partition(
grounded=[
Claim(
text="Unmasked 'email' column is selected in the customer_export view.",
type=EvidenceType.TOOL_MATCH,
evidence_refs=["dlp-scan:DLP-4471:analytics/customer_export.sql:23:class=email"],
),
Claim(
text="The column is exported with no masking or hashing applied.",
type=EvidenceType.SPECIFIC_DATA,
evidence_refs=["schema-review:analytics/customer_export.sql:23"],
),
Claim(
text="Data lineage shows customer_export feeds the public BI dashboard.",
type=EvidenceType.COMPLEMENTARY_FINDING,
evidence_refs=["lineage:DLP-4475:customer_export->bi_public_dashboard"],
),
],
# The author wanted to assert that the records were actually exfiltrated,
# but nothing in evidence proves any unauthorized access occurred — so
# MIRROR's gate keeps that claim out of the grounded set.
ungrounded=[
Claim(
text="The exposed records were accessed by an unauthorized third party.",
type=EvidenceType.INFERENCE,
),
],
)
---------------------------------------------------------------------------¶
Drive an Agent inside a graph node and return the final text¶
---------------------------------------------------------------------------¶
async def _run_agent(agent: Agent, prompt: str) -> str: final = "" async for event in agent.run(prompt): if isinstance(event, TerminateEvent): final = event.final_message or "" return final.strip()
---------------------------------------------------------------------------¶
Graph nodes — one per role¶
---------------------------------------------------------------------------¶
async def gather_node(state: dict[str, Any]) -> dict[str, Any]: agent = _make_agent("analyst", ANALYST_PROMPT, state["model"]) notes = await _run_agent(agent, f"Suspected PII exposure: {state['finding']}") return {"notes": notes}
async def draft_node(state: dict[str, Any]) -> dict[str, Any]: agent = _make_agent("author", AUTHOR_PROMPT, state["model"]) revision = state.get("revision_request", "") prompt = f"Evidence: {state['finding']}\nEvidence notes:\n{state.get('notes', '')}\n" if revision: prompt += f"\nReviewer feedback (apply this): {revision}\n" prompt += "\nWrite the report now."
draft = await _run_agent(agent, prompt)
revisions_done = state.get("revisions_done", 0) + (1 if revision else 0)
return {"draft": draft, "revisions_done": revisions_done}
async def review_node(state: dict[str, Any]) -> dict[str, Any]: """Prose review, then the mechanical grounding gate.
First the reviewer Agent gives a prose verdict (APPROVE / REVISE). Then
— regardless of how persuasive the prose is — the drafted finding is
run through ``ground_finding``. The function returns a ``Evidence`` only
when the evidence partition clears the GSAR proceed threshold; below it
the caller gets an ``Abstention`` and nothing ships. That is what makes
"kill unproven claims" a guarantee rather than a hope.
"""
agent = _make_agent("reviewer", REVIEWER_PROMPT, state["__model__"])
verdict = await _run_agent(agent, f"Draft report to review:\n{state.get('draft', '')}")
approved = verdict.strip().upper().startswith("APPROVE")
revision_request = "" if approved else verdict
# The grounding gate. ground_finding scores the evidence partition and
# returns Evidence | Abstention — an ungrounded claim cannot become a
# Evidence. Tag with the relevant taxonomy IDs so the artifact is
# portable into a privacy register or DPIA (unmasked-PII exposure is
# CWE-359; in the AI-stack threat model it maps to OWASP LLM02 sensitive
# information disclosure and MITRE ATLAS exfiltration via an agent tool).
result = ground_finding(
title="Unmasked PII (email) exposed via analytics customer_export view",
description=(
"The 'email' column is exported without masking in "
"analytics/customer_export.sql:23 and flows to a public BI "
"dashboard; confirmed by DLP scan DLP-4471 and a lineage trace."
),
severity=Severity.HIGH,
asset="analytics-export:customer_export",
remediation="Mask or hash the 'email' column, or drop it from the dashboard feed.",
partition=_evidence_partition(state),
indicators=[Indicator(type=IndicatorType.HOST, value="analytics.corp.example")],
taxonomy=[
OwaspLLM.SENSITIVE_INFORMATION_DISCLOSURE,
AtlasTechnique.EXFILTRATION_VIA_AGENT_TOOL,
],
)
return {
"approved": approved,
"revision_request": revision_request,
"reviewer_verdict": verdict,
"grounded_result": result,
}
---------------------------------------------------------------------------¶
Conditional routing — approve, or send back to the author (capped)¶
---------------------------------------------------------------------------¶
def route_after_review(state: dict[str, Any]) -> str: if state.get("approved"): return "done" if state.get("revisions_done", 0) >= 2: return "done" return "revise"
---------------------------------------------------------------------------¶
Wire it: gather → draft → review → (revise → draft | done → END)¶
---------------------------------------------------------------------------¶
def build_supervisor_graph() -> StateGraph: graph = StateGraph(name="pii-exposure-review-loop") graph.add_node("gather", gather_node) graph.add_node("draft", draft_node) graph.add_node("review", review_node)
graph.add_edge(START, "gather")
graph.add_edge("gather", "draft")
graph.add_edge("draft", "review")
graph.add_conditional_edges(
"review",
route_after_review,
targets={"revise": "draft", "done": END},
)
return graph
---------------------------------------------------------------------------¶
Driver¶
---------------------------------------------------------------------------¶
async def main() -> None: print("Notebook 31: PII-exposure report vs skeptical reviewer") print("=" * 60)
model = get_model()
graph = build_supervisor_graph()
initial = {
"finding": (
"Suspected PII exposure in the analytics-export pipeline — DLP "
"scan DLP-4471 flagged an unmasked 'email' column in the "
"customer_export view"
),
"__model__": model,
}
print(f"\nFinding: {initial['finding']!r}\n")
# Stream node-completion events for live UI feedback, then call
# execute() for the authoritative final state with metrics.
from tulip.multiagent.graph import StreamMode
async for event in graph.stream(initial, mode=StreamMode.NODES):
if event.node_id:
print(f" ✓ {event.node_id}", flush=True)
final = await graph.execute(initial)
final_state = final.final_state
print()
print(f"Revisions: {final_state.get('revisions_done', 0)}")
verdict = final_state.get("reviewer_verdict") or "(unknown)"
print(f"Reviewer: {verdict[:80]}")
print(f"Total time: ~{final.duration_ms:.0f} ms across {final.iterations} graph iterations")
# The grounding gate decides what actually ships.
print()
print("Grounding gate:")
print("-" * 60)
result = final_state.get("grounded_result")
if result is not None and is_finding(result):
print(f" SHIPPED — {result.title}")
print(f" severity={result.severity} gsar_score={result.gsar_score:.2f}")
print(f" taxonomy={[str(t) for t in result.taxonomy]}")
print(f" evidence_refs={result.evidence_refs}")
elif result is not None:
print(f" WITHHELD ({result.decision}) — {result.candidate_title}")
print(f" reason: {result.reason}")
else:
print(" (no result)")
print()
print("Draft report (prose):")
print("-" * 60)
print(final_state.get("draft", "(no draft)"))
if name == "main": asyncio.run(main())
```