Mini Project 8: Role-Aware HR & Policy Assistant
RAG scoped by role, department, and region, plus an output-validation pass, since retrieval filtering alone cannot catch a leaked detail from earlier.
Two Different Security Boundaries, Not One
An HR policy bot has an access-control problem that looks like the multi-tenant isolation problem from this phase's SaaS Support Bot project, plus one it doesn't: an engineer in one country must never retrieve a benefits policy scoped to another region, and nothing should stop the model from casually mentioning a colleague's salary band it happened to see earlier in the same conversation, even once retrieval is correctly scoped for the current question. The first is a retrieval-filtering problem. The second is an output-validation problem. Building only the first and assuming the conversation is safe is the mistake this project exists to prevent.
Setup
// build.gradle
dependencies {
implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter'
implementation 'org.springframework.ai:spring-ai-rag'
implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter'
implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-web'
}Documents are ingested with role, department, and region metadata — the access-control dimensions this project filters on, using the same FilterExpressionBuilder mechanism this phase's SaaS Support Bot project used for tenant isolation:
public Document tagWithAccessScope(Document doc, String requiredRole, String department, String region) {
doc.getMetadata().putAll(Map.of(
"required_role", requiredRole, // "employee", "manager", "hr_admin"
"department", department, // "engineering", "sales", "all"
"region", region // "us", "eu", "in", "all"
));
return doc;
}1. Retrieval Filtered by the Requesting Employee's Access Scope
Build the filter from the authenticated employee's actual attributes — never from anything the request itself claims:
public Advisor policyAdvisorFor(Employee employee) {
Filter.Expression accessFilter = new FilterExpressionBuilder()
.and(
new FilterExpressionBuilder().in("required_role", rolesVisibleTo(employee.role())),
new FilterExpressionBuilder().or(
new FilterExpressionBuilder().eq("region", employee.region()),
new FilterExpressionBuilder().eq("region", "all")
)
)
.build();
DocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.65)
.filterExpression(accessFilter)
.build();
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(retriever)
.queryAugmenter(ContextualQueryAugmenter.builder().allowEmptyContext(false).build())
.build();
}
private List<String> rolesVisibleTo(String employeeRole) {
// an "employee"-tagged doc is visible to everyone; a "manager"-tagged doc
// is visible to managers and hr_admins; hr_admin docs only to hr_admins
return switch (employeeRole) {
case "hr_admin" -> List.of("employee", "manager", "hr_admin");
case "manager" -> List.of("employee", "manager");
default -> List.of("employee");
};
}employee.region() and employee.role() must come from the authenticated session — an identity claim the request can't override — the same warning this phase's SaaS Support Bot project makes about tenantId. A region field in the request body that the client sets is an access-control bypass waiting to be found.
2. Per-Employee Conversation Memory
Each employee's conversation is scoped to them specifically, using the ChatMemory mechanism from this roadmap's second phase — with one HR-specific consequence worth naming explicitly:
public String ask(Employee employee, String question) {
return chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, employee.id()))
.advisors(policyAdvisorFor(employee))
.user(question)
.call()
.content();
}Using employee.id() as the conversation ID means each employee's history is theirs alone — which is exactly what makes section 3's problem real: if an earlier question in this same conversation surfaced a detail that shouldn't appear in the current answer (a colleague's compensation number mentioned while explaining a pay-band policy example), that detail is sitting in memory regardless of whether this turn's retrieval was scoped correctly.
3. Output Validation: the Layer Retrieval Filtering Can't Cover
Retrieval scoping controls what goes in to a given turn. It says nothing about what a model does with information already sitting in conversation memory from a prior turn. Validate the response itself, not just the input:
public record ValidationResult(boolean safe, List<String> concerns) {}
@Component
public class HrOutputValidator {
private final ChatClient validatorClient; // a separate, cheap call — see the callout below
public ValidationResult validate(String response, Employee askingEmployee) {
return validatorClient.prompt()
.system("""
Review this HR assistant response for anything that shouldn't be shared
with an employee of role '%s': specific colleagues' compensation, another
employee's personal details, or policy specific to a region other than '%s'.
""".formatted(askingEmployee.role(), askingEmployee.region()))
.user(response)
.call()
.entity(ValidationResult.class);
}
}public String ask(Employee employee, String question) {
String rawResponse = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, employee.id()))
.advisors(policyAdvisorFor(employee))
.user(question)
.call()
.content();
ValidationResult validation = outputValidator.validate(rawResponse, employee);
if (!validation.safe()) {
auditLog.recordBlockedResponse(employee.id(), validation.concerns());
return "I can't share that level of detail here — please check with your HR partner directly.";
}
return rawResponse;
}This validation call is deliberately a second, separate model call rather than asking the same call to "also check itself" in one pass — a model reviewing its own just-generated output in the same turn is a weaker check than a fresh call whose only job is scrutiny, the same reasoning this roadmap's LLM-as-judge evaluation pattern relies on. Use a smaller, cheaper model here if latency or cost matters more than the marginal accuracy a larger validator model would add — this is a binary safety check, not the primary answer generation.
Putting It Together
@Service
public class HrPolicyAssistantService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
private final HrOutputValidator outputValidator;
private final AuditLogRepository auditLog;
public String ask(Employee employee, String question) {
String rawResponse = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, employee.id()))
.advisors(policyAdvisorFor(employee))
.user(question)
.call()
.content();
ValidationResult validation = outputValidator.validate(rawResponse, employee);
if (!validation.safe()) {
auditLog.recordBlockedResponse(employee.id(), validation.concerns());
return "I can't share that level of detail here — please check with your HR partner directly.";
}
return rawResponse;
}
}Retrieval scoped by role, department, and region controls what the model sees on the way in; per-employee memory keeps conversations separate; output validation catches what neither of those layers can — content that's already in the conversation, not newly retrieved, but still shouldn't be said to this specific employee. Both layers are access-control boundaries; treating only retrieval as security and output as a quality nicety is the gap this project closes.
What's Next
This project pairs naturally with Mini Project 3 (Multi-Tenant SaaS Support Bot) if your product needs both layers — tenant isolation between customers, and role/region isolation within a single customer's own employees.
Frequently asked questions
Why validate the output with a separate model call instead of just being more careful with retrieval filtering?
Because the leak this project worries about most doesn't come from retrieval at all — it comes from conversation memory carrying a detail forward from an earlier, correctly-answered turn into a later turn where repeating it is no longer appropriate. No amount of retrieval-filter precision on the current turn's query touches that; it's a fundamentally different failure mode that needs its own check.
Does output validation on every single response add unacceptable latency for a chat-style assistant?
It adds one more model call's worth of latency, which is real but usually acceptable for HR-sensitive content where correctness matters more than shaving a second off response time. If latency becomes a genuine problem, consider validating only responses whose retrieved context or conversation history includes higher-sensitivity metadata (compensation, PII) rather than every response uniformly.
What should the audit log capture when a response gets blocked?
Enough to investigate a pattern without itself becoming a new copy of the sensitive data — log the employee ID, timestamp, and the validator's concerns() categories (e.g. "mentioned another employee's compensation"), but avoid logging the raw blocked response verbatim in a system with broader read access than the original conversation had.
Could retrieval filtering alone be strengthened enough to make output validation unnecessary?
No — they solve different problems. Retrieval filtering controls what enters context on a given turn; output validation controls what a model does with everything already in context, including things that entered legitimately on an earlier turn. Even a perfect retrieval filter doesn't erase information a model has already generated and that now sits in chat memory.