Sections in this lesson

Tool Design & Permissions

Turn model proposals into narrow, authorized actions with payload-bound idempotency and host-controlled approvals.

Reviewed: · Content owner: Interview Preparator

What you will learn

  • Separate validation, resource authority and approval.
  • Recheck policy at the tool execution boundary.
  • Commit a target mutation and its receipt atomically.

Before you start

The flow

  1. 1.Validate proposal
  2. 2.Check trusted scope
  3. 3.Obtain host approval
  4. 4.Lock resource
  5. 5.Commit receipt

A schema does not grant authority

Only readPlan and completePlan are exposed. The resource ID has a constrained format, while the trusted tenant determines accessible plans. A syntactically valid foreign plan still fails. Model text, a client name or a caller-supplied tenant cannot override this policy.

Approvals are supplied by a trusted host for this run and action. The teaching example uses an approved-resource set; a real approval service should bind the principal, action, payload, run and expiry and record revocation/consumption. Do not deserialize this set from model output.

Put the check at execution too

The runner checks permission before scheduling a tool, and JdbcPlanTools checks it again inside the database transaction. A direct method call cannot bypass the missing-approval check. Resource ownership is enforced by a tenant-qualified query, not by prompt instructions.

Read-only tools can disclose private information even when they do not mutate data. Keep results bounded, avoid arbitrary shell/SQL tools and return safe error outcomes instead of database or credential details.

Idempotency belongs with the effect

A checkpoint may fail after a write succeeds. The target therefore stores its receipt in the same PostgreSQL transaction as the plan status update. Concurrent replay of the same scoped call ID returns the stored result; reuse with a different tool/resource fails.

This proves a local transactional boundary, not universal exactly-once execution across arbitrary services. External payments or messages need their own durable idempotency contract or outbox/reconciliation design.

Try it

JdbcPlanTools.java
java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.interviewpreparator.agents;
import java.util.List;
import java.util.Set;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import static com.interviewpreparator.agents.AgentContracts.*;
/** A local database-backed target with atomic domain mutation and idempotency receipt. */
public final class JdbcPlanTools implements Tools {
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
public JdbcPlanTools(JdbcTemplate jdbc) {
this.jdbc = jdbc;
this.transaction = new TransactionTemplate(new DataSourceTransactionManager(java.util.Objects.requireNonNull(jdbc.getDataSource())));
}
public void initializeLocalSchema() {
jdbc.execute("""
create table if not exists agent_plans (tenant text not null, plan_id text not null,
status text not null, mutation_count int not null, primary key (tenant, plan_id))
""");
jdbc.execute("""
create table if not exists agent_plan_receipts (tenant text not null, owner_id text not null,
conversation text not null, run_id text not null, call_id text not null,
tool text not null, resource text not null, result text not null,
primary key (tenant, owner_id, conversation, run_id, call_id))
""");
}
public void seedPlan(String tenant, String id) {
jdbc.update("insert into agent_plans values (?, ?, 'IN_PROGRESS', 0) on conflict do nothing", tenant, id);
}
@Override public Permission authorize(Scope scope, ToolCall call, Set<String> approvals) {
if (!Set.of("readPlan", "completePlan").contains(call.tool())) return Permission.DENY;
Integer count = jdbc.queryForObject("select count(*) from agent_plans where tenant=? and plan_id=?", Integer.class,
scope.tenant(), call.resource());
if (count == null || count == 0) return Permission.DENY;
return !isReadOnly(call) && !approvals.contains(call.resource()) ? Permission.APPROVAL_REQUIRED : Permission.ALLOW;
}
@Override public boolean isReadOnly(ToolCall call) { return call.tool().equals("readPlan"); }
@Override public String execute(Scope scope, ToolCall call, Set<String> approvals) {
return java.util.Objects.requireNonNull(transaction.execute(status -> {
if (authorize(scope, call, approvals) != Permission.ALLOW) throw new SecurityException("Forbidden");
List<String> plans = jdbc.query("select status from agent_plans where tenant=? and plan_id=? for update",
(row, index) -> row.getString(1), scope.tenant(), call.resource());
if (plans.size() != 1) throw new SecurityException("Forbidden");
List<Receipt> receipts = jdbc.query("""
select tool, resource, result from agent_plan_receipts
where tenant=? and owner_id=? and conversation=? and run_id=? and call_id=?
""", (row, index) -> new Receipt(new ToolCall(call.callId(), row.getString(1), row.getString(2)), row.getString(3)),
scope.tenant(), scope.user(), scope.conversation(), scope.runId(), call.callId());
if (!receipts.isEmpty()) {
if (!receipts.getFirst().call().equals(call)) throw new SecurityException("Idempotency payload mismatch");
return receipts.getFirst().result();
}
String result = plans.getFirst();
if (!isReadOnly(call)) {
jdbc.update("update agent_plans set status='COMPLETED', mutation_count=mutation_count+1 where tenant=? and plan_id=? and status <> 'COMPLETED'",
scope.tenant(), call.resource());
result = "COMPLETED";
}
jdbc.update("insert into agent_plan_receipts values (?, ?, ?, ?, ?, ?, ?, ?)", scope.tenant(), scope.user(),
scope.conversation(), scope.runId(), call.callId(), call.tool(), call.resource(), result);
return result;
}));
}
public int mutations(String tenant, String plan) {
return java.util.Objects.requireNonNull(jdbc.queryForObject("select mutation_count from agent_plans where tenant=? and plan_id=?", Integer.class, tenant, plan));
}
}

examples/spring-ai-lab/src/main/java/com/interviewpreparator/agents/JdbcPlanTools.java · mvn -f examples/spring-ai-lab/pom.xml -Pintegration verify. Java examples target 21. Models and usage are fixtures; no live inference or billing guarantee is implied.

Interview questions

Why recheck permission in the tool?

The execution entry point must remain safe if a caller bypasses the planner or policy changes before execution.

Can an approval in a prompt authorize a write?

No. Approval is trusted application state bound to the intended action and caller.

Why store a payload with the receipt?

To reject reuse of an idempotency key for a different action instead of returning an unrelated cached result.

Practice

Replace the approval set with a signed, expiring action grant and test revocation immediately before execution.

Check your solution

  • Direct execution without approval fails.
  • Foreign resource reads fail.
  • Concurrent duplicate writes have one durable domain effect.

Official references

Continue learning

Category sequence

Agentic AI

Lesson 3 / 6

Was this lesson useful?

Change notes: Phase 3: bilingual agent engineering lesson and executable reference.