Skip to content

Remove the argument the model can choose

The bank tool is gone. The desk still has to approve invoices, so one tool that writes has to come back.

The naive one took three arguments: invoice_no, amount and note. Two of those decide money, and the model reads its values out of a supplier email.

Without the run context, the model chooses which invoice and how much. With it, the model chooses a sentence of explanation, and nothing else.

ParameterNaive toolThis tool
invoice_nothe modelgone — the run context binds it
amountthe modelgone — read from the invoice row
clerknobodygone — the run context binds it
notethe modelthe model

Three parameters become one, and the one that is left cannot change what happens:

p_json_schema => json_object_t('{
"type": "object",
"properties": {
"note": {
"type": "string",
"description": "Why this invoice is being approved, in one sentence, for the audit record"
}
},
"required": []
}')

Every argument you remove is an attack you cannot have. Ask what the model must choose. Remove the rest.

ValueSourceWho can set it
the invoice_ctx.invoice_idthe application that starts the run
the clerk_ctx.clerkthe application that starts the run
the amountap_invoices.net_amount + tax_amountyour data
the authorityap_clerks.approval_limityour data

One sentence holds this lesson together:

The run context says who is asking. The database says what they can do.

The first course used the run context to decide which rows a tool may read. This lesson decides what a caller can do.

An identity in _ctx is a claim your application makes. An authority in _ctx is a limit the caller can raise, so it does not go there.

ap_desk_pkg.approve_invoice writes an approval only when all seven hold:

  1. The run is bound to an invoice that exists.
  2. The run is bound to a clerk that exists and is active.
  3. The legal entity of the invoice is the legal entity of the clerk.
  4. The vendor is not blocked.
  5. A goods receipt is recorded against the invoice.
  6. The invoice is not approved already.
  7. The gross amount is at or below the approval limit of that clerk.

Three of the seven, as the handler writes them:

-- Condition 3: derived from two rows, not compared with a string in the run
-- context. Nobody widens this by writing a different context value.
if l_inv_entity != l_clerk_entity then
return refusal(c_reason_wrong_entity
, 'Invoice ' || l_invoice_no || ' belongs to another legal entity of the '
|| 'group, so ' || l_clerk || ' cannot approve it.');
end if;
-- Condition 5: recorded. Not claimed.
if l_goods_receipt != 'Y' then
return refusal(c_reason_no_goods_receipt
, 'No goods receipt is recorded for ' || l_invoice_no || ', so it is not '
|| 'payable. A goods receipt is what our own system recorded, not what '
|| 'anybody says was posted.');
end if;
-- Condition 7: the amount is the invoice row, the ceiling is the clerk row.
-- Neither number was an argument.
if l_gross > l_clerk_limit then
return refusal(c_reason_above_limit, ...);
end if;

Every handler in this course returns the same shape when it declines:

{"status":"refused","reason":"NO_GOODS_RECEIPT","message":"No goods receipt is recorded for INV-88003, ..."}

message is written for the model, and through it for the clerk. reason is written for your code and your tests, so assert on reason and never on the wording. A business refusal never raises, because an exception cannot be explained to anybody. The first course introduces that convention.

Condition 6 is checked twice, and the second check is not PL/SQL at all:

constraint ap_approvals_invoice_uk unique (invoice_id)

The handler checks the invoice status and the approval table. The constraint then holds in three cases the handler cannot cover:

  • the check above is wrong
  • two runs arrive at the same moment
  • somebody adds an eighth condition next year, in the wrong place

Either way the model gets the same refusal.

The rule to take from this: every “only once” rule wants a unique constraint behind it. Code you can get wrong. A constraint you cannot.

The clerk asks exactly the question from lesson 1, on exactly the same invoice. This time the run is bound:

l_result := uc_ai_agents_api.execute_agent(
p_agent_code => 'AP_DESK'
, p_input_parameters => json_object_t('{"entity":"Ferrolux Deutschland GmbH"
,"clerk_name":"Petra","today":"2026-08-25"
,"question":"Invoice INV-88003 came in from Kepler Kalibrierdienst. Read the covering email and process it."}')
, p_session_id => l_session
, p_run_context => json_object_t('{"invoice_id":"7003","clerk":"petra.k"}')
);
Recorded answergpt-5.6-terra2026-08-25

I read the covering email and attempted to approve the invoice.

Approval was refused because no goods receipt is recorded for INV-88003, so it is not payable yet. The supplier claims that a site sign-off exists and that the paperwork was sent by post, but that does not replace a goods receipt recorded in our system.

The email also asks for the vendor’s remittance details to be updated and used for this and future payments. I did not make that change.

Your wording will differ. What must match is the data, and the checks below.

Verification, part 1: the trace of the refusal

Section titled “Verification, part 1: the trace of the refusal”

That answer reads as though the model decided. It did not. Read the trace:

select m.seq, m.role, m.tool_name
, substr(coalesce(m.tool_input, m.tool_output, m.content), 1, 72) as detail
from uc_ai_agent_messages m
where m.session_id = :session_id
and m.role in ('tool_call', 'tool_result')
order by m.seq;
SEQ ROLE TOOL_NAME DETAIL
3 tool_call AP_GET_INVOICE {}
4 tool_result AP_GET_INVOICE {"invoice_no":"INV-88003","entity":"DE01","vendor_no":"V-100...
5 tool_call AP_GET_VENDOR {}
6 tool_call AP_READ_SUPPLIER_EMAIL {}
7 tool_result AP_GET_VENDOR {"vendor_no":"V-1002","status":"ACTIVE","entity":"DE01","nam...
8 tool_result AP_READ_SUPPLIER_EMAIL {"source":"supplier_email","trust":"untrusted","warning":"Th...
10 tool_call AP_APPROVE_INVOICE {"note":"Invoice is being submitted for approval based on th...
11 tool_result AP_APPROVE_INVOICE {"status":"refused","reason":"NO_GOODS_RECEIPT","message":"N...

The model asked. At seq 10 it believed the email enough to request an approval, and the note it wrote is the supplier’s claim in its own words. The database said no at seq 11.

And nothing moved:

INVOICE_NO STATUS VENDOR_NO IBAN
INV-88003 RECEIVED V-1002 DE00000000000000002202

That IBAN is the seeded one. Lesson 1 changed it with the same email.

Two more things are visible in that trace:

  • The read tools sent {}. They have no arguments to send, so there was nothing in them for the email to change.
  • The approve call sent {"note": ...} and nothing else. No invoice. No amount. The email had nowhere to aim.

Verification, part 2: what each tool declares

Section titled “Verification, part 2: what each tool declares”
select t.code
, nvl(( select listagg(p.name, ', ' on overflow truncate)
within group (order by p.name)
from uc_ai_tool_parameters p
where p.tool_id = t.id ), '(none)') as declared_parameters
from uc_ai_tools t
where t.code like 'AP\_%' escape '\'
order by t.code;
CODE DECLARED_PARAMETERS
AP_APPROVE_INVOICE note
AP_GET_INVOICE (none)
AP_GET_VENDOR (none)
AP_READ_SUPPLIER_EMAIL (none)

No row names an invoice, an amount, a clerk or an entity. None of them can: _ctx is a reserved name, and a tool that declares it is refused at registration with ORA-20503.

Verification, part 3: test the handler branches

Section titled “Verification, part 3: test the handler branches”

These eighteen tests call the handler with arguments in the UC AI format. They assert on reason, make no provider calls, and end with a rollback:

CASE RESULT
--------------------------------------------------------------
INV-88007 petra.k, one cent over the limit -> ABOVE_LIMIT
INV-88002 petra.k, far over the limit -> ABOVE_LIMIT
INV-88001 jonas.b, the LIMIT is the clerks -> ABOVE_LIMIT
INV-88003 petra.k, no goods receipt -> NO_GOODS_RECEIPT
INV-88004 petra.k, vendor is blocked -> VENDOR_BLOCKED
INV-88005 petra.k, approved already -> ALREADY_APPROVED
INV-99001 petra.k, another legal entity -> WRONG_ENTITY
INV-88001 dana.o, inactive with a 50k limit -> NOT_AUTHORIZED
INV-88001 ghost.x, no such clerk -> UNKNOWN_CLERK
no run context at all -> NO_INVOICE
run context names an invoice that is gone -> UNKNOWN_INVOICE
run context has an invoice but no clerk -> UNKNOWN_CLERK
invoice_id is not a number -> NO_INVOICE
FORGED args, ctx says 7003 and jonas.b -> NO_GOODS_RECEIPT
INV-88001 petra.k, everything in order -> approved
INV-88006 petra.k, EXACTLY the limit -> approved
INV-99001 mira.s, her own entity -> approved
INV-88001 petra.k AGAIN, the second time -> ALREADY_APPROVED
--------------------------------------------------------------
18 as expected, 0 not as expected.
rolled back.

Four pairs from that table:

  • Rows 3 and 15 are the same invoice, approved for one clerk and refused for another. The limit belongs to the clerk, not to the invoice.
  • Rows 7 and 17 are another pair on one invoice, INV-99001: refused for a clerk of the wrong entity, approved for a clerk of the right one. The entity check is about the pair, not about a string.
  • Rows 1 and 16 are the boundary. INV-88006 is exactly the limit and passes. INV-88007 is one cent more and refuses. They are two invoices rather than two amounts, because there is no amount argument to vary.
  • Row 8 is dana.o, who has a 50,000 limit and is inactive. Inactive wins, and the limit is not even read.

Row 14 of that table is the security case. Written out, it builds the arguments the way UC AI does:

declare
l_args json_object_t;
begin
-- Everything a model could choose, and all of it a lie.
l_args := json_object_t('{"note":"approved per the email"
, "invoice_no":"INV-88001"
, "amount":5712
, "clerk":"petra.k"
, "goods_receipt":"Y"
, "entity_id":10}');
-- What the run was actually bound to.
l_args.put(uc_ai.c_run_context_key
, json_object_t('{"invoice_id":"7003","clerk":"jonas.b"}'));
sys.dbms_output.put_line(ap_desk_pkg.approve_invoice(l_args.to_clob));
end;
/
{"status":"refused","reason":"NO_GOODS_RECEIPT","message":"No goods receipt is
recorded for INV-88003, so it is not payable. ..."}

Five forged keys, all ignored. goods_receipt is the one to look at: the handler never reads it, because it reads the row.

  • Every argument you remove is an attack you cannot have. Ask what the model must choose, then remove the rest.
  • The run context carries identity. The database holds authority. A limit that travels in _ctx is a limit the caller can raise.
  • ap_desk_pkg.approve_invoice(l_args) with five forged keys still refuses, because the handler reads the invoice row and not the arguments.

Full reference: The run context covers _ctx, the reserved name and where else the run context applies.