09-mini-projects

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.

August 15, 2026
spring-airagrbacmetadata-filteringoutput-validationchat-memorymini-project

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

groovy
// 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:

java
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:

java
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:

java
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:

java
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);
    }
}
java
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

java
@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.