Study guides / CCDV-F / Domain 3

Agents & Workflows · Lesson 5 of 5

3.5 - Reliability Gates in Agentic Workflows

Add deterministic checkpoints to a workflow for the cases where model judgment alone isn't an acceptable control.

Model-driven decision-making - letting Claude decide which tool to call and when - is the default that makes agents flexible, and it's usually the right default: the model adapts to situations you never explicitly mapped out. It has one important exception. Where business logic demands deterministic compliance - a financial threshold, a required approval, a regulatory check - programmatic enforcement should override model discretion rather than relying on the model to remember and apply the rule correctly on every single call. The decision isn't about whether the model is usually reliable enough; it's about whether the consequence of one single failure justifies a deterministic guarantee instead of a probabilistic one.

The decision framework

If a single failure would cost real money, create legal or regulatory exposure, or do something irreversible - use a code-level gate. If it's a formatting preference, a style guideline, or something where occasional deviation genuinely doesn't matter - prompt-based guidance is fine, and adding a hard gate there is unnecessary overhead for no real benefit. A prompt like "always check the approval before proceeding" might work 95%+ of the time; for a compliance rule, that remaining failure rate is exactly the exposure a code-level gate exists to close. The gate doesn't need the model to be unreliable to be justified - it needs the failure cost to be high enough that even rare deviation is unacceptable.

Where the gate has to live: before execution, not after

A gate that runs before a tool executes - intercepting the requested tool call, checking it against a hard-coded rule, and blocking or redirecting it if it fails - can actually prevent a non-compliant action from happening. A check that runs after a tool has already executed can only detect and flag a violation that's already occurred; by the time it fires, the refund has already been issued or the transfer has already gone through. Both have legitimate uses - pre-execution checks for prevention, post-execution checks for normalisation and audit trails - but only pre-execution checks are enforcement in the sense a compliance requirement needs. If the Agent SDK's PreToolUse/PostToolUse hook naming is available to you, that's exactly the distinction those two hook points encode; if you're building against the raw Messages API, the equivalent is simply where in your own loop code you place the check relative to actually calling the tool's implementation.

Common exam distractor

Proposing a check that runs after the sensitive action has already executed - flagging it for review, logging it, queuing it for audit - as the fix for a hard compliance requirement is treating detection as if it were prevention. Post-hoc detection is valuable for catching what slips through, but it does not stop the non-compliant action from having happened. A requirement that must never be violated needs a pre-execution gate.

A rejected call still needs a tool_result

When a gate blocks a tool call, the rejection still has to come back to the model as a proper tool_result - with a clear explanation of why it was blocked - rather than the loop just silently dropping the call or breaking. Two reasons this matters: structurally, the API requires a tool_result for every tool_use_id in the previous assistant turn, so an unanswered blocked call will make the next request invalid. And practically, a model that receives a clear rejection reason ("refund exceeds $500 threshold, requires human approval") can react sensibly - telling the user it needs approval, routing to an escalation path - while a model that gets nothing back has no way to recover gracefully.

Post-execution checks still earn their keep - for a different job

Not every post-execution check is wasted effort just because it can't prevent the action it's checking. Two jobs suit it well. Normalisation: if different tools in a workflow return dates, statuses, or currency values in inconsistent formats, a post-execution step that rewrites every tool result into one consistent shape before the model reads it prevents a large class of misinterpretation errors - the model no longer has to guess whether "P" means "pending" or "processed," or whether a date is DD/MM or MM/DD. Audit and detection: logging every sensitive action, or flagging one for review after the fact, builds a record and a safety net for whatever gets past the pre-execution gates - useful, but a complement to prevention, not a substitute for it. The distinction to hold onto for the exam: normalisation and audit are legitimate post-execution jobs; preventing a non-compliant action from happening is not one of them, no matter how quickly the post-execution check fires.

Key concept

The decision framework is never about whether prompts are "good enough" in the abstract. It's about whether the consequence of a single failure justifies a deterministic guarantee - and once you've decided a gate is warranted, it must run before execution and must still return a tool_result on rejection so the model can react rather than the conversation breaking.

Exam traps

Practice question

A refund-processing agent is instructed via its system prompt never to approve a refund over $500 without human sign-off. An audit later finds one case where it did anyway. What's the correct fix?

  • A Reword the system prompt to state the rule even more emphatically.

    This is still relying on the model to reliably apply a hard rule from a prompt - the same failure mode remains possible, just with stronger wording.

  • B Add a code-level gate that blocks any refund tool call above $500 from executing unless a human-approval flag is present, independent of the model's own reasoning. Correct

    This is deterministic, programmatic enforcement of a hard business rule - exactly the case where code, not prompted instruction, should be the control.

  • C Switch to a more capable model tier, since a stronger model is less likely to make this mistake.

    A stronger model may be less likely to err, but it's still not a guarantee for a hard compliance rule - the fix is a deterministic gate, not a probabilistic improvement.

  • D Lower the threshold to $250 so fewer refunds are at risk.

    This shrinks the exposure but doesn't fix the underlying reliability gap - a refund just above the new threshold has the identical enforcement problem.

Build exercise: Add deterministic pre-execution gates around two sensitive tool calls

Intermediate · 40 minutes

You'll practice:

  1. Define a fake issue_refund(customer_id, amount) tool and, in your application code (not the prompt), write a check function that returns { blocked: true, reason } when amount exceeds $500 and no approved: true flag is present in the input, and { blocked: false } otherwise.

    This is the actual enforcement mechanism, isolated from the tool's own logic - a check that runs in your code regardless of what the model decided or how it phrased its reasoning.

    You should see: A standalone function that correctly returns blocked: true for a $750 refund with no approval, and blocked: false for a $200 refund or a $750 refund with approved: true.

    Hints
    1. Should this check live inside the tool's own implementation, or somewhere your loop calls before the tool implementation ever runs?
    2. Keep it as a separate function your loop calls before invoking the actual issue_refund logic - that separation is what makes it a gate rather than just validation buried inside the tool.
    3. function checkRefundGate(input) {
        if (input.amount > 500 && !input.approved) {
          return { blocked: true, reason: `Refund of $${input.amount} exceeds the $500 threshold and requires human approval.` };
        }
        return { blocked: false };
      }
  2. Wire the gate into your agentic loop's tool_use handling: before executing any tool named issue_refund, run checkRefundGate first, and only call the real tool implementation if it returns blocked: false.

    This is where 'before execution' becomes real - the placement of the check relative to the actual tool call is what turns it from a detection mechanism into a prevention mechanism.

    You should see: A blocked refund attempt never reaching your issue_refund implementation at all (e.g. a console log or side-effect inside the real implementation never fires for a blocked call).

    Hints
    1. In your tool_use branch, what has to happen between finding the tool_use block and calling the tool's actual implementation?
    2. Insert the gate check as the first thing that happens once you know the tool name is issue_refund, before calling executeTool. If blocked, skip the real implementation entirely.
    3. for (const toolUse of toolUses) {
        if (toolUse.name === "issue_refund") {
          const gate = checkRefundGate(toolUse.input);
          if (gate.blocked) {
            results.push({ type: "tool_result", tool_use_id: toolUse.id, content: gate.reason, is_error: true });
            continue; // never calls the real issue_refund implementation
          }
        }
        const output = executeTool(toolUse.name, toolUse.input);
        results.push({ type: "tool_result", tool_use_id: toolUse.id, content: JSON.stringify(output) });
      }
  3. Test that a blocked call still results in a valid next API request - that is, confirm the tool_result for the blocked call is present and correctly matched, and that the model's next turn reacts sensibly (e.g. tells the user it needs approval) rather than the conversation erroring out.

    A gate that blocks execution but forgets the tool_result breaks the loop structurally (Lesson 3.1) - this step proves the rejection is handled as a first-class part of the conversation, not a special-cased dead end.

    You should see: The follow-up API call succeeding (no structural error about a missing tool_result), and the model's resulting text acknowledging that approval is required rather than claiming the refund succeeded.

    Hints
    1. What would happen to the next messages.create() call if the blocked tool_use's id had no matching tool_result at all?
    2. The API would reject the request as malformed since every tool_use_id from the prior assistant turn needs a corresponding tool_result. Verify by intentionally omitting it once and observing the error, then confirm it disappears once the rejection message is included.
    3. const followUp = await client.messages.create({
        model: "claude-sonnet-5",
        max_tokens: 300,
        tools,
        messages: [...messages, { role: "assistant", content: response.content }, { role: "user", content: results }]
      });
      console.log(followUp.content.find(b => b.type === "text")?.text);
      // Expect language like "this refund needs manager approval" rather than a false success claim.
  4. Add a second, prerequisite-style gate: block transfer_funds unless an aml_check tool has already returned a passing result earlier in the same session, tracked via a simple session-scoped state object.

    This is a different shape of gate than a threshold check - it depends on session history rather than the current call's input alone, and is a common real-world pattern (a required prior step, not just a value check).

    You should see: transfer_funds blocked with a clear reason when no prior aml_check has passed in this session, and allowed once one has.

    Hints
    1. Where does the state that says 'AML check passed' need to live so it's visible to a later, unrelated tool call in the same loop?
    2. Keep a small mutable object scoped to the current session/loop instance (not global, not per-call) that gets updated when aml_check succeeds, and is read by the transfer_funds gate.
    3. const sessionState = { amlPassed: false };
      
      function checkTransferGate(toolName, input, state) {
        if (toolName === "transfer_funds" && !state.amlPassed) {
          return { blocked: true, reason: "International transfer requires a passing AML check first. Run aml_check before retrying." };
        }
        return { blocked: false };
      }
      // After a successful aml_check tool execution:
      // if (result.status === "pass") sessionState.amlPassed = true;
  5. Run both the negative case (blocked, prerequisite unmet) and the positive case (allowed, prerequisite satisfied) for the AML gate end to end, and confirm no side effect from transfer_funds occurs during the blocked attempt.

    Proving the gate is airtight means checking both directions, not just that it blocks once - and confirming zero side effects on a blocked attempt is what separates a real gate from one that fires a warning but still lets the action through.

    You should see: A blocked transfer_funds call producing no observable effect from the real implementation (e.g. a mock ledger/log stays unchanged), and the same call succeeding and producing the expected effect once aml_check has passed in that session.

    Hints
    1. How would you detect, in a test, whether the real transfer_funds implementation actually ran versus was skipped by the gate?
    2. Have your mock transfer_funds implementation push to a simple in-memory log array. Assert that array is empty after a blocked attempt, and contains exactly one entry after the allowed attempt.
    3. const transferLog = [];
      function realTransferFunds(input) { transferLog.push(input); return { status: "completed" }; }
      
      // Blocked case
      await runLoopStep("transfer_funds", { to: "IBAN-1", amount: 10000 }, sessionState);
      console.log("No side effect on block:", transferLog.length === 0);
      
      // Satisfy prerequisite, then retry
      await runLoopStep("aml_check", { customer_id: "C-1" }, sessionState);
      await runLoopStep("transfer_funds", { to: "IBAN-1", amount: 10000 }, sessionState);
      console.log("Side effect after AML pass:", transferLog.length === 1);

Sources