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.Validate proposal
- 2.Check trusted scope
- 3.Obtain host approval
- 4.Lock resource
- 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
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.