The review flag
Scans return ALLOW or BLOCK. A needs_review flag marks the uncertain ones, for tuning and for routing to a second model.
A scan result carries one decision to act on, action, which is "allow" or
"block". When the engine was uncertain about that decision, the result also
carries needs_review=True. The flag does not hold the request: the action
is already final, and the flag marks it as one worth a second look.
| Field | Type | Meaning |
|---|---|---|
action | "allow" | "block" | What to enforce. Already collapsed by the gateway |
engine_action | ScanAction | None | The engine's own label before collapsing: "allow", "review", "block", "hard_block" |
needs_review | bool | True when the engine's action was "review". "ood" does not set it |
result = client.analyze(user_input)
result.action # "block"
result.engine_action # "review"
result.needs_review # TrueHow a review becomes allow or block
The engine can answer ALLOW, REVIEW, BLOCK, or HARD_BLOCK. The
gateway turns that into one of two actions before the response is sent, using
a per-app setting for each content type.
| Engine action | action you receive |
|---|---|
ALLOW, OOD | "allow" |
BLOCK, HARD_BLOCK, SANDBOX | "block" (never collapsed to "allow") |
REVIEW, SOFT_BLOCK, AMBIGUOUS | "allow" or "block", per the setting below |
Shadow mode still forces "allow", and a boundary rule can still force
"block", after the collapse. Neither changes engine_action or
needs_review.
The reviewCollapse setting
Each app has one allow or block value per content type. It decides what a
REVIEW becomes for that type.
| Content type | Scan methods | Default |
|---|---|---|
user_prompt | analyze | block |
tool_call | scan_tool_call | block |
tool_output | scan_tool_output | allow |
rag_chunk | scan_chunk, each chunk of scan_retrieval | allow |
rag_context | scan_context | allow |
llm_response | scan_response | allow |
An absent or unrecognised value uses the default for that type. A /scan
request that sets provenance uses the setting for that provenance. Knowledge
base scans are not affected.
Set it per app in the dashboard, on the app's Settings page, in the card titled "Results that need review". The card has one Block or Allow choice for each content type above, labeled User prompts, Tool calls, Tool results, Retrieved chunks, Assembled context and Model responses, and the flag stays visible either way.
Default behavior change
With the defaults, a user prompt or tool call the engine labels REVIEW
is returned as "block". Previously the result carried "review", which
default_should_block did not block. To keep the previous pass-through for
an app, set that content type to allow.
Choosing a setting
block converts every REVIEW into a block, which catches more attacks and
also blocks some benign input. allow lets every REVIEW through, which
blocks less and misses more. The figures below were measured on our own
evaluation sets, with 95% Wilson confidence intervals. They show the effect
of the setting on those sets, not a prediction for your traffic.
| Content (benign / malicious rows) | Setting | Benign blocked | Malicious blocked |
|---|---|---|---|
| Prompts, benchmark set (432 / 984) | allow | 0.0% [0.0, 0.9] | 25.4% [22.8, 28.2] |
block | 5.1% [3.4, 7.6] | 81.5% [79.0, 83.8] | |
| Prompts, benign injection-like set (102 / none) | allow | 0.0% [0.0, 3.6] | n/a |
block | 21.6% [14.7, 30.5] | n/a | |
| Tool outputs, synthetic JSON (100 / 120) | allow | 0.0% [0.0, 3.7] | 25.8% [18.8, 34.3] |
block | 5.0% [2.2, 11.2] | 90.8% [84.3, 94.8] | |
| Tool calls, synthetic JSON (60 / 60) | allow | 0.0% [0.0, 6.0] | 6.7% [2.6, 15.9] |
block | 3.3% [0.9, 11.4] | 66.7% [54.1, 77.3] | |
| Retrieved chunks, real documents (1374 / none) | allow | 4.9% [3.9, 6.1] | n/a |
block | 50.4% [47.8, 53.1] | n/a | |
| Assembled context, real documents (280 / none) | allow | 22.5% [18.0, 27.7] | n/a |
block | 81.4% [76.5, 85.5] | n/a |
What the measurements support:
- For prompts,
blockraises recall substantially and costs false positives, most visibly on benign prompts that resemble injections. - For retrieved documents,
blockwould block roughly half or more of the real benign documents measured, which is whyrag_chunkandrag_contextdefault toallow. - On real benign documents the flag itself is frequent (45.6% [42.9, 48.2] of the retrieved chunks measured), so for retrieved content it is a coarse review queue.
- The tool call and tool output sets are small and synthetic. There is no
labelled data for
llm_response.
Using the flag
Tune thresholds with it
needs_review marks the decisions nearest the engine's cut points, so it is
the natural place to look when deciding whether a setting is too strict or too
loose.
- Filter the dashboard's Jobs list by Verdict, choosing Needs review, and read a sample.
Flagged items that were blocked and are fine argue for
allowon that content type. Flagged items that were allowed and are not fine argue forblock. - Record those judgments with the job feedback controls.
- Count flagged scans per period from the
reviewandneedsReviewcounts of the Analytics API, or per content type in your own logs.
Route flagged items to a second model
A flagged result is a cheap trigger for a second opinion. The pattern is the
same in every integration: act on action as usual, and call a second
reviewer only when needs_review is True. second_opinion below is your
own function, backed by whatever model or service you choose.
def second_opinion(*, text: str, stage: str, risk: float | None, rationale: str) -> str:
"""Return "safe" or "unsafe". Replace with a call to your reviewer."""
raise NotImplementedError
result = client.analyze(user_input)
if result.needs_review:
verdict = second_opinion(
text=user_input,
stage="prompt",
risk=result.risk,
rationale=result.rationale,
)
if verdict == "unsafe":
raise PermissionError("Blocked after review")
# verdict == "safe": proceed even if result.action was "block"
elif result.action == "block":
raise PermissionError("Blocked")With the OpenAI Agents SDK guardrails,
pass on_review. It is called once per scan whenever needs_review is
True, whether or not the scan tripped the guardrail, and before the
tripwire decision. on_review takes the ScanResult and may be a plain
function or a coroutine function. An error raised inside it does not affect
the scan: it goes to the optional on_error(exc) callback, or is dropped when
on_error is not set.
from tawaguard.agents_sdk import tawa_input_guardrail
def log_hook_error(exc):
logger.warning("on_review failed: %s", exc)
def queue_for_review(result):
review_queue.put({"risk": result.risk, "rationale": result.rationale})
guardrail = tawa_input_guardrail(
tawa, on_review=queue_for_review, on_error=log_hook_error
)on_review observes. It does not change whether the guardrail trips; to
override a block after a second opinion, use the raw SDK pattern above.
on_review is also accepted by the tool guardrails
(tawa_tool_input_guardrail, tawa_tool_output_guardrail,
tawa_tool_guardrails and guard_function_tools, from 0.3.0). It behaves the
same way: it is called with the ScanResult of a tool call or tool result
whose needs_review is True, and cannot change the decision.
Retrieval scans
scan_retrieval applies the setting to each chunk. aggregate.worst_action
and aggregate.flagged_chunk_ids are computed from the collapsed actions.
aggregate.needs_review and aggregate.review_chunk_ids list the chunks the
engine flagged, including the ones enforced as "allow".
result = client.scan_retrieval(query, chunks)
result.aggregate.flagged_chunk_ids # enforced as block
result.aggregate.review_chunk_ids # flagged for review, blocked or notWhat is stored
| Where | Value |
|---|---|
Scan response decision.action | ALLOW or BLOCK after the collapse |
Scan response decision.engine_action | The engine's label |
Persisted job (result_action, dashboard, Analytics API, exports) | The engine's label, so a flagged job reads REVIEW |
Live event action | The engine's label, with needs_review and enforced_action beside it |
The Analytics API counts persisted jobs, so its review count (also
returned as needsReview) includes flagged scans that you received as
"allow" or "block".
Older SDKs and gateways
The new fields are additive.
- An SDK without
needs_reviewignores the fields it does not know. Itsactionis still a valid value ("allow"or"block"). - A new SDK against a gateway that does not send the fields derives
needs_reviewfromaction == "review"andengine_actionfromaction. Against such a gatewayactionis the engine's raw label, so"review"can still appear. - Do not require the fields to be present. Treat a missing
needs_reviewasFalsein clients you maintain yourself.