The review flag
Scans return ALLOW or BLOCK. A needsReview 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 needsReview: 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 |
engineAction | ScanAction | The engine's own label before collapsing: allow, review, block, hard_block |
needsReview | boolean | true when the engine's action was review. ood does not set it |
const result = await tawa.analyze(userInput);
result.action; // "block"
result.engineAction; // "review"
result.needsReview; // 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 engineAction or needsReview.
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 | scanToolCall | block |
tool_output | scanToolOutput | allow |
rag_chunk | scanChunk, each chunk of scanRetrieval | allow |
rag_context | scanContext | allow |
llm_response | scanResponse | 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 the
SDK's default shouldBlock 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
needsReview 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 needsReview is true. secondOpinion below is your own
function, backed by whatever model or service you choose.
declare function secondOpinion(input: {
text: string;
stage: string;
risk: number | null;
rationale: string;
}): Promise<"safe" | "unsafe">;
const result = await tawa.analyze(userInput);
if (result.needsReview) {
const verdict = await secondOpinion({
text: userInput,
stage: "prompt",
risk: result.risk,
rationale: result.rationale,
});
if (verdict === "unsafe") throw new Error("Blocked after review");
// verdict === "safe": proceed even if result.action was "block"
} else if (result.action === "block") {
throw new Error("Blocked");
}With the Vercel AI SDK middleware,
pass onReview. It is called once per scan whenever needsReview is true,
whether or not the scan blocked, and before the block decision. It receives
(result, stage, text): the ScanResult, "prompt" or "response", and the
scanned text. It may return a promise, which the middleware awaits. An error
thrown inside it does not affect the scan: it goes to the optional onError
callback, or is dropped when onError is not set.
const model = tawa(openai("gpt-5"), tawaClient, {
scanOptions: { sessionId },
onReview: async (result, stage, text) => {
await reviewQueue.add({ stage, text, risk: result.risk, rationale: result.rationale });
},
onError: (error) => console.error("onReview failed", error),
});onReview observes. It does not change whether the call is blocked; to
override a block after a second opinion, use the raw SDK pattern above.
Watch flags on the live stream
Decision events carry needsReview and enforcedAction. action on an
event stays the engine's label, so a flagged tool call that was blocked reads
as follows.
const stream = tawa.subscribe(sessionId);
stream.on("tawa:event", (event) => {
if (event.stage === "audit" || event.stage === "_meta") return;
if (event.needsReview) {
console.log(event.stage, event.action, "->", event.enforcedAction);
}
});
// tool review -> blockOn the wire the same fields are needs_review and enforced_action; see the
live event stream.
Retrieval scans
scanRetrieval applies the setting to each chunk. aggregate.worstAction and
aggregate.flaggedChunkIds are computed from the collapsed actions.
aggregate.needsReview and aggregate.reviewChunkIds list the chunks the
engine flagged, including the ones enforced as allow.
const { aggregate } = await tawa.scanRetrieval(query, chunks);
aggregate.flaggedChunkIds; // enforced as block
aggregate.reviewChunkIds; // 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 |
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
needsReviewignores the fields it does not know. Itsactionis still a valid value (alloworblock). - A new SDK against a gateway that does not send the fields derives
needsReviewfromaction === "review"andengineActionfromaction. Against such a gatewayactionis the engine's raw label, soreviewcan still appear. - Do not require the fields to be present. Treat a missing
needsReviewasfalsein clients you maintain yourself.