Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .tegami/2026-08-10-assistant-governed-file-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
packages:
orgmemory: minor
subject: Use governed files as Assistant evidence
---

## Features

- Upload up to three supported documents from the Assistant composer, publish
them to a chosen Knowledge Space, and wait for governed ingestion before use.
- Keep the exact ordered file selection across a failed retry and cite the same
permission-verified evidence used for the answer.

## Security

- Recheck conversation ownership, current Source revision, actor access, and
active retrieval-engine readiness before each selected-file turn.
- Keep selected files as a hard retrieval ceiling through graph expansion and
citation output; direct provider files and transient attachment bypasses
remain unavailable.
14 changes: 13 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,11 @@ Executive facts and resolve organization/department existence without exposing
Organization persistence or roles. Source Ledger resolves tenant-scoped ready
revision plus validated blob state through `SourceCitationEvidenceQuery`, so
citation opening consumes immutable evidence rather than revision/blob
persistence. Asset has no direct dependency on Retrieval and is a closed
persistence. Parent Knowledge also exposes the exact `knowledge::evidence`
named interface for governed byte registration and exact Source/revision state.
Source Ledger implements it through the canonical upload and query services;
Assistant consumes it without importing Source Ledger persistence, parsing, or
processing types. Asset has no direct dependency on Retrieval and is a closed
nested module with an exact outgoing dependency allowlist. Parent Knowledge
exposes the stable permission-aware
search contract, immutable evidence, secure result, and verified grounding as
Expand Down Expand Up @@ -331,6 +335,14 @@ an in-flight turn may finish under its request snapshot and is bounded by the
configured turn timeout. Missing, unknown, stale, unsupported, changed, or
denied retrieval decisions fail closed.

Assistant governed-file turns add an immutable `KnowledgeEvidenceSelection`
after actor authorization. Canonical retrieval intersects the selected Assets
before ranking; GraphRAG carries the same ceiling through seed, expansion,
closure verification, and citation output. The selection pins exact binding,
Source, revision, and Asset identity, and every selected Source must contribute
usable final evidence before generation. Upload remains the ordinary durable
Source pipeline; the API never parses or embeds the multipart bytes.

ACL evidence is sealed and append-only. ACL rotation appends a new generation
and compare-and-set advances the current head. The current head has a 24-hour
freshness requirement; the ingestion snapshot remains a historical ceiling.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;

record AssistantChatRequest(
@NotBlank @Size(max = 1_000) String message,
@NotBlank @Size(max = 8_000) String message,
Integer limit,
UUID conversationId,
UUID modelActivationId) {
UUID modelActivationId,
@Size(max = 3) List<UUID> evidenceBindingIds) {

AssistantChatRequest(
String message,
Integer limit,
UUID conversationId,
UUID modelActivationId) {
this(message, limit, conversationId, modelActivationId, List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.orgmemory.core.assistant.AssistantAssetToolService;
import com.orgmemory.core.assistant.AssistantAgentModelPort;
import com.orgmemory.core.assistant.AssistantAssetTraceRecorder;
import com.orgmemory.core.assistant.AssistantEvidenceAnswerabilityPort;
import com.orgmemory.core.assistant.AssistantService;
import com.orgmemory.core.assistant.observability.AssistantStageEventSink;
import com.orgmemory.core.assistant.observability.AssistantTurnEvent;
Expand All @@ -14,6 +15,7 @@
import com.orgmemory.core.assetregistry.promptcontract.PromptAssistantOperations;
import com.orgmemory.core.knowledge.retrieval.CanonicalHybridKnowledgeSearch;
import com.orgmemory.core.knowledge.retrieval.GraphRagKnowledgeRetrievalService;
import com.orgmemory.core.knowledge.graph.GraphEvidenceAnswerabilityQuery;
import com.orgmemory.core.knowledge.search.PermissionAwareKnowledgeSearch;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.observation.ObservationRegistry;
Expand Down Expand Up @@ -71,6 +73,29 @@ PermissionAwareKnowledgeSearch permissionAwareKnowledgeSearch(
};
}

@Bean
AssistantEvidenceAnswerabilityPort assistantEvidenceAnswerability(
AssistantProperties properties,
ObjectProvider<GraphEvidenceAnswerabilityQuery> graphAnswerability) {
return switch (properties.retrievalEngine()) {
case CANONICAL_HYBRID -> source ->
AssistantEvidenceAnswerabilityPort.Answerability.ready();
case GRAPH_RAG -> source -> {
var graph = graphAnswerability.getIfAvailable(() -> {
throw new IllegalStateException(
"Assistant retrieval engine GRAPH_RAG requires graph evidence readiness");
});
var answerability = graph.evaluate(source);
return switch (answerability.state()) {
case INDEXING -> AssistantEvidenceAnswerabilityPort.Answerability.indexing();
case READY -> AssistantEvidenceAnswerabilityPort.Answerability.ready();
case FAILED -> AssistantEvidenceAnswerabilityPort.Answerability.failed(
answerability.failureCode());
};
};
};
}

@Bean
AssistantService assistantService(
PermissionAwareKnowledgeSearch retrieval,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.orgmemory.api.assistant;

import com.orgmemory.api.ApiRequestException;
import com.orgmemory.api.security.CurrentActorProvider;
import com.orgmemory.core.assistant.AssistantAnswerFeedbackView;
import com.orgmemory.core.assistant.AssistantAnswerSentiment;
Expand All @@ -8,6 +9,10 @@
import com.orgmemory.core.assistant.AssistantConversationMessageView;
import com.orgmemory.core.assistant.AssistantConversationService;
import com.orgmemory.core.assistant.AssistantConversationSummary;
import com.orgmemory.core.assistant.AssistantEvidenceTurnClaim;
import com.orgmemory.core.assistant.AssistantEvidenceBindingView;
import com.orgmemory.core.assistant.AssistantEvidenceService;
import com.orgmemory.core.assistant.AssistantEvidenceUploadService;
import com.orgmemory.core.assistant.AssistantService;
import com.orgmemory.core.assistant.AssistantTurn;
import com.orgmemory.core.assistant.AssistantTurnRef;
Expand All @@ -17,12 +22,15 @@
import com.orgmemory.core.ai.AssistantModelSelectionRef;
import com.orgmemory.core.knowledge.retrieval.CitationEvidenceReference;
import com.orgmemory.core.knowledge.retrieval.CitationEvidenceService;
import com.orgmemory.core.knowledge.search.KnowledgeEvidenceSelection;
import com.orgmemory.core.organization.CurrentActor;
import com.orgmemory.core.permission.KnowledgeClassification;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
Expand All @@ -43,8 +51,10 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import reactor.core.publisher.Flux;
import tools.jackson.databind.ObjectMapper;

Expand Down Expand Up @@ -75,6 +85,8 @@ class AssistantController {
private final AssistantModelAuthorityService modelAuthority;
private final CitationEvidenceService citationEvidence;
private final AssistantRetrievalScheduler retrievalScheduler;
private final AssistantEvidenceUploadService evidenceUploads;
private final AssistantEvidenceService evidence;
private final ObjectMapper json;

AssistantController(
Expand All @@ -85,6 +97,8 @@ class AssistantController {
AssistantModelAuthorityService modelAuthority,
CitationEvidenceService citationEvidence,
AssistantRetrievalScheduler retrievalScheduler,
AssistantEvidenceUploadService evidenceUploads,
AssistantEvidenceService evidence,
ObjectMapper json) {
this.assistant = assistant;
this.conversations = conversations;
Expand All @@ -93,9 +107,66 @@ class AssistantController {
this.modelAuthority = modelAuthority;
this.citationEvidence = citationEvidence;
this.retrievalScheduler = retrievalScheduler;
this.evidenceUploads = evidenceUploads;
this.evidence = evidence;
this.json = json;
}

@PostMapping(path = "/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
@Operation(
operationId = "uploadAssistantEvidence",
summary = "Upload one governed file and bind it to an Assistant conversation")
AssistantEvidenceBindingView uploadEvidence(
@RequestPart("file") MultipartFile file,
@RequestParam(required = false) UUID conversationId,
@RequestParam UUID knowledgeSpaceId,
@RequestParam(defaultValue = "CONFIDENTIAL")
KnowledgeClassification classification,
Authentication authentication) {
CurrentActor actor = actors.current(authentication);
try (var content = file.getInputStream()) {
return evidenceUploads.upload(
actor,
conversationId,
knowledgeSpaceId,
classification,
file.getOriginalFilename(),
file.getSize(),
content);
} catch (IOException failure) {
throw new ApiRequestException(
"The uploaded Assistant file could not be read",
failure);
}
}

@GetMapping("/conversations/{conversationId}/evidence/{bindingId}")
@Operation(
operationId = "getAssistantEvidence",
summary = "Read the active-engine preparation state of one owned binding")
AssistantEvidenceBindingView evidence(
@PathVariable UUID conversationId,
@PathVariable UUID bindingId,
Authentication authentication) {
return evidence.get(
actors.current(authentication),
conversationId,
bindingId);
}

@GetMapping("/conversations/{conversationId}/evidence")
@Operation(
operationId = "listAssistantEvidence",
summary = "List governed file bindings for one owned conversation")
List<AssistantEvidenceBindingView> evidence(
@PathVariable UUID conversationId,
Authentication authentication) {
return evidence.list(
actors.current(authentication),
conversationId);
}

@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(operationId = "streamAssistantChat", summary = "Stream an answer from permission-verified knowledge")
ResponseEntity<Flux<ServerSentEvent<String>>> chat(
Expand All @@ -108,11 +179,24 @@ ResponseEntity<Flux<ServerSentEvent<String>>> chat(
request.modelActivationId());
AssistantModelSelectionRef modelSelection = modelAuthority.selectionRef(
routeAuthority);
AssistantTurnRef turnRef = conversations.beginTurn(
actor,
request.conversationId(),
request.message(),
modelSelection);
List<UUID> requestedEvidence = request.evidenceBindingIds() == null
? List.of()
: request.evidenceBindingIds();
AssistantEvidenceTurnClaim turnClaim = requestedEvidence.isEmpty()
? new AssistantEvidenceTurnClaim(
conversations.beginTurn(
actor,
request.conversationId(),
request.message(),
modelSelection),
KnowledgeEvidenceSelection.unrestricted())
: conversations.beginTurnWithEvidence(
actor,
request.conversationId(),
request.message(),
modelSelection,
requestedEvidence);
AssistantTurnRef turnRef = turnClaim.turn();
UUID conversationId = turnRef.conversationId();
UUID assistantMessageId = UUID.randomUUID();
Flux<AssistantStreamPart> parts = Flux.defer(() -> {
Expand All @@ -131,7 +215,8 @@ ResponseEntity<Flux<ServerSentEvent<String>>> chat(
requestId,
conversationId.toString(),
routeAuthority,
turnStartedAtNanos))
turnStartedAtNanos,
turnClaim.selection()))
.flatMapMany(turn -> completedTurnParts(
actor,
turnRef,
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/test/java/com/orgmemory/api/OpenApiContractTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
Expand Down Expand Up @@ -50,6 +52,26 @@ void theCommittedScimContractDescribesOnlyTheLiveScimApi() throws Exception {
verifyContract("scim", "scim-openapi.json", "https://memory.company.com");
}

@Test
void assistantEvidenceContractHasNoBindExistingSourceEndpoint() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
JsonNode contract = objectMapper.readTree(
Files.readString(repositoryRoot().resolve("contracts/openapi.json")));
TreeSet<String> evidencePaths = new TreeSet<>();
contract.path("paths").fieldNames().forEachRemaining(path -> {
if (path.startsWith("/api/assistant") && path.contains("evidence")) {
evidencePaths.add(path);
}
});

assertEquals(
Set.of(
"/api/assistant/evidence",
"/api/assistant/conversations/{conversationId}/evidence",
"/api/assistant/conversations/{conversationId}/evidence/{bindingId}"),
evidencePaths);
}

private void verifyContract(String group, String fileName, String serverUrl)
throws Exception {
String generated = mockMvc.perform(get("/v3/api-docs/{group}", group))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
import com.orgmemory.api.security.CurrentActorProvider;
import com.orgmemory.core.ai.AssistantModelAuthorityService;
import com.orgmemory.core.assistant.AssistantConversationService;
import com.orgmemory.core.assistant.AssistantEvidenceService;
import com.orgmemory.core.assistant.AssistantEvidenceUploadService;
import com.orgmemory.core.assistant.AssistantService;
import com.orgmemory.core.knowledge.retrieval.CitationEvidenceService;
import jakarta.validation.Validation;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.ObjectMapper;

Expand All @@ -25,9 +29,9 @@ void enforcesTheMessageLimitBoundary() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
var accepted = validator.validate(
new AssistantChatRequest("a".repeat(1_000), null, null, null));
new AssistantChatRequest("a".repeat(8_000), null, null, null));
var rejected = validator.validate(
new AssistantChatRequest("a".repeat(1_001), null, null, null));
new AssistantChatRequest("a".repeat(8_001), null, null, null));

assertEquals(0, accepted.size());
assertEquals(1, rejected.size());
Expand All @@ -37,6 +41,27 @@ void enforcesTheMessageLimitBoundary() {
}
}

@Test
void limitsOneTurnToThreeEvidenceBindings() {
try (var factory = Validation.buildDefaultValidatorFactory()) {
var violations = factory.getValidator().validate(new AssistantChatRequest(
"Compare these files",
null,
null,
null,
List.of(
UUID.randomUUID(),
UUID.randomUUID(),
UUID.randomUUID(),
UUID.randomUUID())));

assertEquals(1, violations.size());
assertEquals(
"evidenceBindingIds",
violations.iterator().next().getPropertyPath().toString());
}
}

@Test
void rejectsAnOversizedMessageBeforeOpeningTheStreamOrCreatingATurn() throws Exception {
var assistant = mock(AssistantService.class);
Expand All @@ -55,13 +80,15 @@ void rejectsAnOversizedMessageBeforeOpeningTheStreamOrCreatingATurn() throws Exc
modelAuthority,
citationEvidence,
retrievalScheduler,
mock(AssistantEvidenceUploadService.class),
mock(AssistantEvidenceService.class),
json))
.build();

mvc.perform(post("/api/assistant/chat")
.contentType(APPLICATION_JSON)
.accept(TEXT_EVENT_STREAM)
.content("{\"message\":\"" + "a".repeat(1_001) + "\"}"))
.content("{\"message\":\"" + "a".repeat(8_001) + "\"}"))
.andExpect(status().isBadRequest());

verifyNoInteractions(
Expand Down
Loading