From 9f86704c9461af5343203c02ff8c7d1c81a529c1 Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Sun, 2 Aug 2026 10:00:54 +0700 Subject: [PATCH 1/4] refactor(knowledge): verify graph evidence through retrieval --- ARCHITECTURE.md | 8 +- .../graph/KnowledgeGraphCurationService.java | 80 ++---- .../KnowledgeGraphExplorerConfiguration.java | 6 +- .../graph/KnowledgeGraphExplorerService.java | 40 ++- .../graph/KnowledgeGraphExportService.java | 41 ++- .../core/knowledge/graph/package-info.java | 7 +- .../CanonicalGraphEvidenceVerifier.java | 79 ++++++ .../retrieval/GraphEvidenceVerifier.java | 18 ++ .../retrieval/VerifiedGraphEvidenceScope.java | 131 +++++++++ .../knowledge/retrieval/package-info.java | 10 +- .../core/ModulithVerificationTests.java | 14 +- .../KnowledgeGraphCurationServiceTests.java | 41 +-- .../KnowledgeGraphExplorerServiceTests.java | 18 +- .../KnowledgeGraphExportServiceTests.java | 22 +- .../CanonicalGraphEvidenceVerifierTests.java | 264 ++++++++++++++++++ .../plan.md | 48 +++- docs/specs/domains/secure-graph-rag.md | 7 +- docs/specs/domains/secure-retrieval.md | 9 +- docs/tests/domains/secure-graph-rag.md | 6 +- docs/tests/domains/secure-retrieval.md | 3 +- 20 files changed, 672 insertions(+), 180 deletions(-) create mode 100644 core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java create mode 100644 core/src/main/java/com/orgmemory/core/knowledge/retrieval/GraphEvidenceVerifier.java create mode 100644 core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java create mode 100644 core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6d386202..db5d4790 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -163,8 +163,12 @@ exposes the stable permission-aware search contract, immutable evidence, secure result, and verified grounding as the exact `knowledge::search` named interface. Assistant and Asset Registry consume that parent interface without importing Retrieval implementation types. -Retrieval remains explicitly open while its Graph verifier and remaining -sibling adapters are replaced by intentional APIs. The +Graph exploration, export, and curation obtain their immutable authorized +evidence snapshot and exact current governing-evidence decision through the +Retrieval-owned `GraphEvidenceVerifier`; Graph does not import Retrieval scope +resolution, candidate, or store implementation types. Retrieval remains +explicitly open while its remaining sibling adapters are replaced by +intentional APIs. The provider-neutral object-storage port is exposed as the `knowledge::storage` named interface. Leased database jobs carry ingestion work across processes. A specific Knowledge Asset diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java index cf4b771f..69048488 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java @@ -1,12 +1,10 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeUnavailableException; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.knowledge.asset.KnowledgeProjectionNamespaces; import com.orgmemory.core.shared.error.KnowledgeResourceNotFoundException; import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; -import com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope; -import com.orgmemory.core.knowledge.retrieval.SecureKnowledgeRetrievalStore; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.knowledge.asset.KnowledgeAssetGraphQuery; import com.orgmemory.core.authorization.AuthorizationDecision; @@ -45,8 +43,7 @@ public class KnowledgeGraphCurationService { private final KnowledgeSpaceQuery spaces; private final KnowledgeAssetGraphQuery assets; private final RelationshipAuthorizationPort authorization; - private final KnowledgeEvidenceScopeResolver evidenceScopes; - private final SecureKnowledgeRetrievalStore canonicalEvidence; + private final GraphEvidenceVerifier evidenceVerifier; private final GraphExportReader graphs; private final GraphCurationStore curations; private final ModelInvocationCache modelCache; @@ -56,8 +53,7 @@ public class KnowledgeGraphCurationService { KnowledgeSpaceQuery spaces, KnowledgeAssetGraphQuery assets, RelationshipAuthorizationPort authorization, - KnowledgeEvidenceScopeResolver evidenceScopes, - SecureKnowledgeRetrievalStore canonicalEvidence, + GraphEvidenceVerifier evidenceVerifier, GraphExportReader graphs, GraphCurationStore curations, ModelInvocationCache modelCache, @@ -65,8 +61,7 @@ public class KnowledgeGraphCurationService { this.spaces = spaces; this.assets = assets; this.authorization = authorization; - this.evidenceScopes = evidenceScopes; - this.canonicalEvidence = canonicalEvidence; + this.evidenceVerifier = evidenceVerifier; this.graphs = graphs; this.curations = curations; this.modelCache = modelCache; @@ -83,7 +78,7 @@ public GraphCurationRecord apply( actor, command.knowledgeSpaceId()); ProjectionNamespace namespace = namespace(actor.organizationId(), command.knowledgeSpaceId()); - ResolvedKnowledgeEvidenceScope resolved = + VerifiedGraphEvidenceScope resolved = resolve(actor, decision.policyVersion()); requireCurrentScope(command, resolved); CurationProvenance provenance = new CurationProvenance( @@ -198,10 +193,9 @@ public void deactivate( requireSpace(actor, knowledgeSpaceId); AuthorizationDecision decision = requirePermission(actor, knowledgeSpaceId); - ResolvedKnowledgeEvidenceScope resolved = + VerifiedGraphEvidenceScope resolved = resolve(actor, decision.policyVersion()); - if (resolved.aclGenerationByKnowledgeSpace() - .getOrDefault(knowledgeSpaceId, 0L) + if (resolved.authorizationGeneration(knowledgeSpaceId) != authorizationGeneration) { throw new KnowledgeRetrievalUnavailableException( "Knowledge graph authorization changed before curation"); @@ -229,30 +223,20 @@ private void requireGoverningEvidence( CurrentActor actor, UUID knowledgeSpaceId, com.orgmemory.graphrag.model.EvidenceReference evidence, - ResolvedKnowledgeEvidenceScope resolved) { + VerifiedGraphEvidenceScope resolved) { if (!actor.organizationId().equals(evidence.organizationId())) { throw new KnowledgeResourceNotFoundException(); } assets.requireInSpace( actor.organizationId(), evidence.knowledgeAssetId(), knowledgeSpaceId); - var spaceScope = resolved.forKnowledgeSpace(knowledgeSpaceId); - if (!spaceScope.includes( + if (!resolved.includes( + knowledgeSpaceId, evidence.organizationId(), evidence.knowledgeAssetId())) { throw new OrgMemoryAccessDeniedException( "Governing evidence is not visible to the current actor"); } - var candidates = canonicalEvidence.recheck( - retrievalScope(resolved), - java.util.List.of(Objects.requireNonNull( - evidence.chunkId(), "governing evidence chunkId"))); - boolean current = candidates.size() == 1 - && candidates.getFirst().knowledgeAssetId() - .equals(evidence.knowledgeAssetId()) - && candidates.getFirst().sourceRevisionId() - .equals(evidence.sourceRevisionId()) - && candidates.getFirst().currentAclSnapshotId() - .equals(evidence.aclSnapshotId()); - if (!current) { + if (!evidenceVerifier.isCurrentGoverningEvidence( + resolved, knowledgeSpaceId, evidence)) { throw new OrgMemoryAccessDeniedException( "Governing evidence is stale or unavailable"); } @@ -260,11 +244,10 @@ private void requireGoverningEvidence( private void requireCurrentScope( KnowledgeGraphCurationCommand command, - ResolvedKnowledgeEvidenceScope resolved) { + VerifiedGraphEvidenceScope resolved) { UUID spaceId = command.knowledgeSpaceId(); - if (!resolved.knowledgeSpaceIds().contains(spaceId) - || resolved.aclGenerationByKnowledgeSpace() - .getOrDefault(spaceId, 0L) + if (!resolved.includesKnowledgeSpace(spaceId) + || resolved.authorizationGeneration(spaceId) != command.authorizationGeneration()) { throw new KnowledgeRetrievalUnavailableException( "Knowledge graph authorization changed before curation"); @@ -272,7 +255,7 @@ private void requireCurrentScope( } private void requireVisibleEntity( - ResolvedKnowledgeEvidenceScope resolved, + VerifiedGraphEvidenceScope resolved, ProjectionNamespace namespace, UUID knowledgeSpaceId, UUID entityId) { @@ -285,7 +268,7 @@ private void requireVisibleEntity( } private void requireVisibleIdentity( - ResolvedKnowledgeEvidenceScope resolved, + VerifiedGraphEvidenceScope resolved, ProjectionNamespace namespace, UUID knowledgeSpaceId, com.orgmemory.graphrag.curation.GraphIdentityKind kind, @@ -305,14 +288,15 @@ private void requireVisibleIdentity( } } - private ResolvedKnowledgeEvidenceScope resolve( + private VerifiedGraphEvidenceScope resolve( CurrentActor actor, String authorizationModelId) { try { - return evidenceScopes.resolve(actor, authorizationModelId); - } catch (KnowledgeEvidenceScopeUnavailableException unavailable) { + return evidenceVerifier.verifyScope(actor, authorizationModelId); + } catch (KnowledgeRetrievalUnavailableException unavailable) { throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions are temporarily unavailable"); + "Knowledge graph permissions are temporarily unavailable", + unavailable); } } @@ -320,27 +304,15 @@ private void requireUnchangedScope( CurrentActor actor, UUID knowledgeSpaceId, String authorizationModelId, - ResolvedKnowledgeEvidenceScope initial) { - ResolvedKnowledgeEvidenceScope current = + VerifiedGraphEvidenceScope initial) { + VerifiedGraphEvidenceScope current = resolve(actor, authorizationModelId); - if (!initial.forKnowledgeSpace(knowledgeSpaceId) - .authorizedAssetIds() - .equals(current.forKnowledgeSpace(knowledgeSpaceId) - .authorizedAssetIds()) - || initial.aclGenerationByKnowledgeSpace() - .getOrDefault(knowledgeSpaceId, 0L) - != current.aclGenerationByKnowledgeSpace() - .getOrDefault(knowledgeSpaceId, 0L)) { + if (!initial.hasSameAssetsAndGeneration(current, knowledgeSpaceId)) { throw new KnowledgeRetrievalUnavailableException( "Knowledge graph authorization changed during curation"); } } - private static SecureKnowledgeRetrievalStore.RetrievalScope retrievalScope( - ResolvedKnowledgeEvidenceScope scope) { - return scope.toRetrievalScope(); - } - private void requireSpace(CurrentActor actor, UUID knowledgeSpaceId) { if (!spaces.isActive(actor.organizationId(), knowledgeSpaceId)) { throw new OrgMemoryAccessDeniedException( diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerConfiguration.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerConfiguration.java index 0eb5f6e5..7c23e829 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerConfiguration.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerConfiguration.java @@ -1,6 +1,6 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.authorization.RelationshipAuthorizationPort; import com.orgmemory.core.knowledge.space.KnowledgeSpaceQuery; import com.orgmemory.core.permission.PermissionAuditService; @@ -17,14 +17,14 @@ public class KnowledgeGraphExplorerConfiguration { KnowledgeGraphExplorerService knowledgeGraphExplorerService( KnowledgeSpaceQuery spaces, RelationshipAuthorizationPort authorization, - KnowledgeEvidenceScopeResolver evidenceScopes, + GraphEvidenceVerifier evidenceVerifier, GraphExportReader graphs, GraphExplorerProperties properties, PermissionAuditService audit) { return new KnowledgeGraphExplorerService( spaces, authorization, - evidenceScopes, + evidenceVerifier, graphs, properties, audit); diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java index 49425d56..6d69ea93 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java @@ -1,10 +1,9 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeUnavailableException; import com.orgmemory.core.knowledge.asset.KnowledgeProjectionNamespaces; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; -import com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.authorization.AuthorizationDecision; import com.orgmemory.core.authorization.PermissionKey; import com.orgmemory.core.authorization.RelationshipAuthorizationPort; @@ -41,7 +40,7 @@ public class KnowledgeGraphExplorerService { private final KnowledgeSpaceQuery spaces; private final RelationshipAuthorizationPort authorization; - private final KnowledgeEvidenceScopeResolver evidenceScopes; + private final GraphEvidenceVerifier evidenceVerifier; private final GraphExportReader graphs; private final GraphExplorerProperties properties; private final PermissionAuditService audit; @@ -49,13 +48,13 @@ public class KnowledgeGraphExplorerService { public KnowledgeGraphExplorerService( KnowledgeSpaceQuery spaces, RelationshipAuthorizationPort authorization, - KnowledgeEvidenceScopeResolver evidenceScopes, + GraphEvidenceVerifier evidenceVerifier, GraphExportReader graphs, GraphExplorerProperties properties, PermissionAuditService audit) { this.spaces = spaces; this.authorization = authorization; - this.evidenceScopes = evidenceScopes; + this.evidenceVerifier = evidenceVerifier; this.graphs = graphs; this.properties = properties; this.audit = audit; @@ -98,9 +97,9 @@ private KnowledgeGraphView explore( String requestId, String policyVersion, int attempt) { - ResolvedKnowledgeEvidenceScope initial = + VerifiedGraphEvidenceScope initial = resolve(actor, policyVersion); - if (!initial.knowledgeSpaceIds().contains(knowledgeSpaceId)) { + if (!initial.includesKnowledgeSpace(knowledgeSpaceId)) { return empty( actor, knowledgeSpaceId, @@ -114,7 +113,7 @@ private KnowledgeGraphView explore( initial.forKnowledgeSpace(knowledgeSpaceId), namespace); - ResolvedKnowledgeEvidenceScope current = + VerifiedGraphEvidenceScope current = resolve(actor, policyVersion); if (!sameSpaceScope(initial, current, knowledgeSpaceId)) { if (attempt == 0) { @@ -154,8 +153,7 @@ private KnowledgeGraphView explore( entityLimit, properties.maximumRelationLimit(), maximumDepth, - initial.aclGenerationByKnowledgeSpace() - .getOrDefault(knowledgeSpaceId, 0L), + initial.authorizationGeneration(knowledgeSpaceId), curationDecision.allowed()); audit.record(new PermissionAuditCommand( actor.organizationId(), @@ -191,14 +189,15 @@ private String requireSpaceAccess( return decision.policyVersion(); } - private ResolvedKnowledgeEvidenceScope resolve( + private VerifiedGraphEvidenceScope resolve( CurrentActor actor, String policyVersion) { try { - return evidenceScopes.resolve(actor, policyVersion); - } catch (KnowledgeEvidenceScopeUnavailableException unavailable) { + return evidenceVerifier.verifyScope(actor, policyVersion); + } catch (KnowledgeRetrievalUnavailableException unavailable) { throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions are temporarily unavailable"); + "Knowledge graph permissions are temporarily unavailable", + unavailable); } } @@ -407,15 +406,10 @@ private static boolean contains(String value, String needle) { } private static boolean sameSpaceScope( - ResolvedKnowledgeEvidenceScope initial, - ResolvedKnowledgeEvidenceScope current, + VerifiedGraphEvidenceScope initial, + VerifiedGraphEvidenceScope current, UUID knowledgeSpaceId) { - return initial.authorizationModelId() - .equals(current.authorizationModelId()) - && initial.forKnowledgeSpace(knowledgeSpaceId) - .authorizationFingerprint() - .equals(current.forKnowledgeSpace(knowledgeSpaceId) - .authorizationFingerprint()); + return initial.hasSameAuthorizationFingerprint(current, knowledgeSpaceId); } private String normalizeQuery(String query) { diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java index eb340ecb..fdb440a4 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java @@ -1,10 +1,9 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeUnavailableException; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.knowledge.asset.KnowledgeProjectionNamespaces; import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; -import com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.authorization.PermissionKey; import com.orgmemory.core.authorization.RelationshipAuthorizationPort; import com.orgmemory.core.authorization.RelationshipAuthorizationQuery; @@ -35,7 +34,7 @@ public class KnowledgeGraphExportService { private final KnowledgeSpaceQuery spaces; private final RelationshipAuthorizationPort authorization; - private final KnowledgeEvidenceScopeResolver evidenceScopes; + private final GraphEvidenceVerifier evidenceVerifier; private final GraphExportReader reader; private final GraphExportFormatter formatter = new GraphExportFormatter(); private final PermissionAuditService audit; @@ -43,12 +42,12 @@ public class KnowledgeGraphExportService { KnowledgeGraphExportService( KnowledgeSpaceQuery spaces, RelationshipAuthorizationPort authorization, - KnowledgeEvidenceScopeResolver evidenceScopes, + GraphEvidenceVerifier evidenceVerifier, GraphExportReader reader, PermissionAuditService audit) { this.spaces = spaces; this.authorization = authorization; - this.evidenceScopes = evidenceScopes; + this.evidenceVerifier = evidenceVerifier; this.reader = reader; this.audit = audit; } @@ -75,10 +74,11 @@ public GraphExportFormatter.Artifact export( if (!entry.allowed()) { throw accessDenied(); } - ResolvedKnowledgeEvidenceScope resolved; + VerifiedGraphEvidenceScope resolved; try { - resolved = evidenceScopes.resolve(actor, entry.policyVersion()); - } catch (KnowledgeEvidenceScopeUnavailableException unavailable) { + resolved = evidenceVerifier.verifyScope( + actor, entry.policyVersion()); + } catch (KnowledgeRetrievalUnavailableException unavailable) { throw new KnowledgeRetrievalUnavailableException( "Knowledge graph permissions changed while preparing the export", unavailable); @@ -88,12 +88,14 @@ public GraphExportFormatter.Artifact export( var document = reader.read( resolved.forKnowledgeSpace(knowledgeSpaceId), namespace); - ResolvedKnowledgeEvidenceScope current; + VerifiedGraphEvidenceScope current; try { - current = evidenceScopes.resolve(actor, entry.policyVersion()); - } catch (KnowledgeEvidenceScopeUnavailableException unavailable) { + current = evidenceVerifier.verifyScope( + actor, entry.policyVersion()); + } catch (KnowledgeRetrievalUnavailableException unavailable) { throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions changed while preparing the export"); + "Knowledge graph permissions changed while preparing the export", + unavailable); } if (!sameSpaceScope(resolved, current, knowledgeSpaceId)) { throw new KnowledgeRetrievalUnavailableException( @@ -116,18 +118,11 @@ public GraphExportFormatter.Artifact export( } private static boolean sameSpaceScope( - ResolvedKnowledgeEvidenceScope first, - ResolvedKnowledgeEvidenceScope second, + VerifiedGraphEvidenceScope first, + VerifiedGraphEvidenceScope second, UUID knowledgeSpaceId) { return first.authorizationModelId().equals(second.authorizationModelId()) - && first.forKnowledgeSpace(knowledgeSpaceId) - .authorizedAssetIds() - .equals(second.forKnowledgeSpace(knowledgeSpaceId) - .authorizedAssetIds()) - && first.aclGenerationByKnowledgeSpace() - .getOrDefault(knowledgeSpaceId, 0L) - .equals(second.aclGenerationByKnowledgeSpace() - .getOrDefault(knowledgeSpaceId, 0L)); + && first.hasSameAssetsAndGeneration(second, knowledgeSpaceId); } private static OrgMemoryAccessDeniedException accessDenied() { diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/package-info.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/package-info.java index 693c362d..f00e010b 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/package-info.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/package-info.java @@ -1,9 +1,10 @@ /** * Knowledge graph indexing, processing profiles, exploration, curation, and export. * - *

Asset, Source Ledger, ACL, Space, and embedding-profile state crosses owned query or registry - * boundaries instead of persistence types. The closed boundary exposes only root-package Graph - * contracts and declares every outgoing application-module dependency explicitly. + *

Asset, Source Ledger, ACL, Space, Retrieval evidence, and embedding-profile state crosses + * owned query, verifier, or registry boundaries instead of persistence and implementation types. + * The closed boundary exposes only root-package Graph contracts and declares every outgoing + * application-module dependency explicitly. */ @org.springframework.modulith.ApplicationModule( type = org.springframework.modulith.ApplicationModule.Type.CLOSED, diff --git a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java new file mode 100644 index 00000000..d2e799f5 --- /dev/null +++ b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java @@ -0,0 +1,79 @@ +package com.orgmemory.core.knowledge.retrieval; + +import com.orgmemory.core.organization.CurrentActor; +import com.orgmemory.graphrag.model.EvidenceReference; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +class CanonicalGraphEvidenceVerifier implements GraphEvidenceVerifier { + + private final KnowledgeEvidenceScopeResolver evidenceScopes; + private final SecureKnowledgeRetrievalStore canonicalEvidence; + + CanonicalGraphEvidenceVerifier( + KnowledgeEvidenceScopeResolver evidenceScopes, + SecureKnowledgeRetrievalStore canonicalEvidence) { + this.evidenceScopes = evidenceScopes; + this.canonicalEvidence = canonicalEvidence; + } + + @Override + public VerifiedGraphEvidenceScope verifyScope( + CurrentActor actor, + String expectedAuthorizationModelId) { + try { + ResolvedKnowledgeEvidenceScope resolved = evidenceScopes.resolve( + Objects.requireNonNull(actor, "actor"), + expectedAuthorizationModelId); + return new VerifiedGraphEvidenceScope( + resolved.organizationId(), + resolved.actorUserId(), + resolved.actorDepartmentId(), + resolved.actorExecutive(), + resolved.authorizationModelId(), + resolved.evaluatedAt(), + resolved.assetIdsByKnowledgeSpace(), + resolved.aclGenerationByKnowledgeSpace()); + } catch (KnowledgeEvidenceScopeUnavailableException unavailable) { + throw new KnowledgeRetrievalUnavailableException( + "Canonical Graph evidence scope is unavailable", + unavailable); + } + } + + @Override + public boolean isCurrentGoverningEvidence( + VerifiedGraphEvidenceScope scope, + UUID knowledgeSpaceId, + EvidenceReference evidence) { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(knowledgeSpaceId, "knowledgeSpaceId"); + Objects.requireNonNull(evidence, "evidence"); + if (!scope.includes( + knowledgeSpaceId, + evidence.organizationId(), + evidence.knowledgeAssetId())) { + return false; + } + var candidates = canonicalEvidence.recheck( + scope.toRetrievalScope(), + List.of(Objects.requireNonNull( + evidence.chunkId(), "governing evidence chunkId"))); + return candidates.size() == 1 + && candidates.getFirst().organizationId() + .equals(evidence.organizationId()) + && candidates.getFirst().knowledgeAssetId() + .equals(evidence.knowledgeAssetId()) + && candidates.getFirst().sourceRevisionId() + .equals(evidence.sourceRevisionId()) + && candidates.getFirst().currentAclSnapshotId() + .equals(evidence.aclSnapshotId()) + && candidates.getFirst().chunkId() + .equals(evidence.chunkId()); + } +} diff --git a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/GraphEvidenceVerifier.java b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/GraphEvidenceVerifier.java new file mode 100644 index 00000000..437d1c54 --- /dev/null +++ b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/GraphEvidenceVerifier.java @@ -0,0 +1,18 @@ +package com.orgmemory.core.knowledge.retrieval; + +import com.orgmemory.core.organization.CurrentActor; +import com.orgmemory.graphrag.model.EvidenceReference; +import java.util.UUID; + +/** Retrieval-owned canonical authorization boundary for Graph evidence reads. */ +public interface GraphEvidenceVerifier { + + VerifiedGraphEvidenceScope verifyScope( + CurrentActor actor, + String expectedAuthorizationModelId); + + boolean isCurrentGoverningEvidence( + VerifiedGraphEvidenceScope scope, + UUID knowledgeSpaceId, + EvidenceReference evidence); +} diff --git a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java new file mode 100644 index 00000000..5a3fe7ec --- /dev/null +++ b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java @@ -0,0 +1,131 @@ +package com.orgmemory.core.knowledge.retrieval; + +import com.orgmemory.graphrag.authorization.AuthorizedEvidenceScope; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Immutable permission-verified evidence snapshot exposed to Graph use cases. */ +public record VerifiedGraphEvidenceScope( + UUID organizationId, + UUID actorUserId, + UUID actorDepartmentId, + boolean actorExecutive, + String authorizationModelId, + Instant evaluatedAt, + Map> assetIdsByKnowledgeSpace, + Map aclGenerationByKnowledgeSpace) { + + public VerifiedGraphEvidenceScope { + Objects.requireNonNull(organizationId, "organizationId"); + Objects.requireNonNull(actorUserId, "actorUserId"); + authorizationModelId = required( + authorizationModelId, "authorizationModelId"); + Objects.requireNonNull(evaluatedAt, "evaluatedAt"); + assetIdsByKnowledgeSpace = immutableSets(assetIdsByKnowledgeSpace); + aclGenerationByKnowledgeSpace = Map.copyOf(Objects.requireNonNull( + aclGenerationByKnowledgeSpace, + "aclGenerationByKnowledgeSpace")); + if (!aclGenerationByKnowledgeSpace.keySet() + .equals(assetIdsByKnowledgeSpace.keySet())) { + throw new IllegalArgumentException( + "ACL generations and Knowledge Space scopes must align"); + } + if (aclGenerationByKnowledgeSpace.values().stream() + .anyMatch(generation -> generation == null || generation < 0)) { + throw new IllegalArgumentException( + "ACL generations must be non-negative"); + } + } + + public boolean includesKnowledgeSpace(UUID knowledgeSpaceId) { + return assetIdsByKnowledgeSpace.containsKey( + Objects.requireNonNull(knowledgeSpaceId, "knowledgeSpaceId")); + } + + public boolean includes( + UUID knowledgeSpaceId, + UUID candidateOrganizationId, + UUID candidateAssetId) { + return forKnowledgeSpace(knowledgeSpaceId) + .includes(candidateOrganizationId, candidateAssetId); + } + + public AuthorizedEvidenceScope forKnowledgeSpace(UUID knowledgeSpaceId) { + UUID spaceId = Objects.requireNonNull( + knowledgeSpaceId, "knowledgeSpaceId"); + return new AuthorizedEvidenceScope( + organizationId, + actorUserId, + actorDepartmentId, + actorExecutive, + assetIdsByKnowledgeSpace.getOrDefault(spaceId, Set.of()), + authorizationModelId, + authorizationGeneration(spaceId), + evaluatedAt); + } + + public long authorizationGeneration(UUID knowledgeSpaceId) { + return aclGenerationByKnowledgeSpace.getOrDefault( + Objects.requireNonNull(knowledgeSpaceId, "knowledgeSpaceId"), + 0L); + } + + public boolean hasSameAuthorizationFingerprint( + VerifiedGraphEvidenceScope other, + UUID knowledgeSpaceId) { + Objects.requireNonNull(other, "other"); + return forKnowledgeSpace(knowledgeSpaceId) + .authorizationFingerprint() + .equals(other.forKnowledgeSpace(knowledgeSpaceId) + .authorizationFingerprint()); + } + + public boolean hasSameAssetsAndGeneration( + VerifiedGraphEvidenceScope other, + UUID knowledgeSpaceId) { + Objects.requireNonNull(other, "other"); + return forKnowledgeSpace(knowledgeSpaceId) + .authorizedAssetIds() + .equals(other.forKnowledgeSpace(knowledgeSpaceId) + .authorizedAssetIds()) + && authorizationGeneration(knowledgeSpaceId) + == other.authorizationGeneration(knowledgeSpaceId); + } + + SecureKnowledgeRetrievalStore.RetrievalScope toRetrievalScope() { + LinkedHashSet assetIds = new LinkedHashSet<>(); + assetIdsByKnowledgeSpace.values().forEach(assetIds::addAll); + return new SecureKnowledgeRetrievalStore.RetrievalScope( + organizationId, + actorUserId, + actorDepartmentId, + actorExecutive, + assetIds.stream().sorted().toList(), + authorizationModelId, + evaluatedAt); + } + + private static Map> immutableSets( + Map> source) { + Map> copy = new LinkedHashMap<>(); + Objects.requireNonNull(source, "assetIdsByKnowledgeSpace") + .forEach((space, assets) -> copy.put( + Objects.requireNonNull(space, "knowledgeSpaceId"), + Set.copyOf(Objects.requireNonNull( + assets, "assetIds")))); + return Map.copyOf(copy); + } + + private static String required(String value, String field) { + String normalized = value == null ? "" : value.strip(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(field + " is required"); + } + return normalized; + } +} diff --git a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/package-info.java b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/package-info.java index 99b3b5aa..94f81cd4 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/package-info.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/package-info.java @@ -6,10 +6,12 @@ * Graph indexing now resolves profiles through the registry instead of profile persistence. * Catalog, text-chunk, vector-literal, and projection-namespace values belong to Asset and are * consumed here one way. Top-level search consumers cross the parent-owned - * {@code knowledge::search} interface instead of this implementation package. The module remains - * open while its Graph verifier and remaining sibling-module adapters are replaced by intentional - * interfaces during the Knowledge module-closing phase. Asset, Organization, and Source Ledger - * citation reads already cross owner-defined queries. + * {@code knowledge::search} interface instead of this implementation package. Graph exploration, + * export, and curation consume a Retrieval-owned canonical evidence verifier and immutable verified + * snapshot instead of scope resolution or retrieval-store implementation types. The module remains + * open while its remaining sibling-module adapters are replaced by intentional interfaces during + * the Knowledge module-closing phase. Asset, Organization, and Source Ledger citation reads already + * cross owner-defined queries. */ @org.springframework.modulith.ApplicationModule( type = org.springframework.modulith.ApplicationModule.Type.OPEN) diff --git a/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java b/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java index c7d3d116..e9f460c8 100644 --- a/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java +++ b/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java @@ -400,12 +400,9 @@ void graphConsumesOnlyRetrievalGraphContracts() { Set.of( "com.orgmemory.core.knowledge.retrieval.EmbeddingProfileRef", "com.orgmemory.core.knowledge.retrieval.EmbeddingProfileRegistry", - "com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver", + "com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier", "com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException", - "com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope", - "com.orgmemory.core.knowledge.retrieval.SecureKnowledgeRetrievalStore", - "com.orgmemory.core.knowledge.retrieval.SecureKnowledgeRetrievalStore$RetrievalScope", - "com.orgmemory.core.knowledge.retrieval.SecureRetrievalCandidate"), + "com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope"), consumedTypes); } @@ -727,12 +724,9 @@ void knowledgeRetrievalTemporaryOpenBoundaryDoesNotGainNewConsumers() { Set.of( "com.orgmemory.core.knowledge.retrieval.EmbeddingProfileRef", "com.orgmemory.core.knowledge.retrieval.EmbeddingProfileRegistry", - "com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver", + "com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier", "com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException", - "com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope", - "com.orgmemory.core.knowledge.retrieval.SecureKnowledgeRetrievalStore", - "com.orgmemory.core.knowledge.retrieval.SecureKnowledgeRetrievalStore$RetrievalScope", - "com.orgmemory.core.knowledge.retrieval.SecureRetrievalCandidate"), + "com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope"), consumedInternalTypes); } diff --git a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java index 3272ebb5..066c7201 100644 --- a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java +++ b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java @@ -1,10 +1,8 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.shared.error.KnowledgeResourceNotFoundException; -import com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope; -import com.orgmemory.core.knowledge.retrieval.SecureKnowledgeRetrievalStore; -import com.orgmemory.core.knowledge.retrieval.SecureRetrievalCandidate; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.knowledge.asset.KnowledgeAssetGraphQuery; import com.orgmemory.core.knowledge.space.KnowledgeSpaceQuery; @@ -28,7 +26,6 @@ import com.orgmemory.graphrag.export.GraphExportReader; import com.orgmemory.graphrag.model.EvidenceReference; import java.time.Instant; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -51,10 +48,8 @@ class KnowledgeGraphCurationServiceTests { mock(KnowledgeAssetGraphQuery.class); private final RelationshipAuthorizationPort authorization = mock(RelationshipAuthorizationPort.class); - private final KnowledgeEvidenceScopeResolver evidenceScopes = - mock(KnowledgeEvidenceScopeResolver.class); - private final SecureKnowledgeRetrievalStore canonicalEvidence = - mock(SecureKnowledgeRetrievalStore.class); + private final GraphEvidenceVerifier evidenceVerifier = + mock(GraphEvidenceVerifier.class); private final GraphExportReader graphs = mock(GraphExportReader.class); private final GraphCurationStore store = mock(GraphCurationStore.class); private final ModelInvocationCache modelCache = @@ -66,8 +61,7 @@ class KnowledgeGraphCurationServiceTests { spaces, assets, authorization, - evidenceScopes, - canonicalEvidence, + evidenceVerifier, graphs, store, modelCache, @@ -80,8 +74,8 @@ void setUpSpaceAndEvidence() { when(spaces.isActive(ORGANIZATION_ID, SPACE_ID)) .thenReturn(true); when(store.append(any(), any())).thenAnswer(invocation -> invocation.getArgument(1)); - when(evidenceScopes.resolve(actor, "model-v1")) - .thenReturn(new ResolvedKnowledgeEvidenceScope( + when(evidenceVerifier.verifyScope(actor, "model-v1")) + .thenReturn(new VerifiedGraphEvidenceScope( ORGANIZATION_ID, USER_ID, null, @@ -90,25 +84,8 @@ void setUpSpaceAndEvidence() { Instant.parse("2026-07-24T00:00:00Z"), Map.of(SPACE_ID, Set.of(ASSET_ID)), Map.of(SPACE_ID, 7L))); - when(canonicalEvidence.recheck(any(), any())) - .thenReturn(List.of(new SecureRetrievalCandidate( - ORGANIZATION_ID, - CHUNK_ID, - ASSET_ID, - UUID.randomUUID(), - REVISION_ID, - "Policy", - "Approved policy", - "source://policy", - null, - null, - null, - 0, - ACL_ID, - ACL_ID, - "model-v1", - UUID.randomUUID(), - 1))); + when(evidenceVerifier.isCurrentGoverningEvidence(any(), any(), any())) + .thenReturn(true); } @Test diff --git a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerServiceTests.java b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerServiceTests.java index b457e55d..527d30e8 100644 --- a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerServiceTests.java +++ b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerServiceTests.java @@ -1,8 +1,8 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; -import com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.knowledge.space.KnowledgeSpaceQuery; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -68,8 +68,8 @@ class KnowledgeGraphExplorerServiceTests { private final KnowledgeSpaceQuery spaces = mock(KnowledgeSpaceQuery.class); private final RelationshipAuthorizationPort authorization = mock(RelationshipAuthorizationPort.class); - private final KnowledgeEvidenceScopeResolver evidenceScopes = - mock(KnowledgeEvidenceScopeResolver.class); + private final GraphEvidenceVerifier evidenceVerifier = + mock(GraphEvidenceVerifier.class); private final GraphExportReader reader = mock(GraphExportReader.class); private final PermissionAuditService audit = mock(PermissionAuditService.class); @@ -86,7 +86,7 @@ class KnowledgeGraphExplorerServiceTests { new KnowledgeGraphExplorerService( spaces, authorization, - evidenceScopes, + evidenceVerifier, reader, properties, audit); @@ -97,7 +97,7 @@ void setUpEntryPermission() { .thenReturn(true); when(authorization.check(any())) .thenReturn(AuthorizationDecision.allow("model-v1")); - when(evidenceScopes.resolve(actor, "model-v1")) + when(evidenceVerifier.verifyScope(actor, "model-v1")) .thenReturn(scope(Set.of(ASSET_ID), 9L)); when(reader.read(any(), any())).thenReturn(document()); } @@ -212,7 +212,7 @@ void deniesBeforeReadingWhenTheSpaceIsNotAuthorized() { @Test void failsClosedWhenAuthorizationKeepsChangingDuringRead() { - when(evidenceScopes.resolve(actor, "model-v1")) + when(evidenceVerifier.verifyScope(actor, "model-v1")) .thenReturn( scope(Set.of(ASSET_ID), 9L), scope(Set.of(ASSET_ID), 10L), @@ -228,10 +228,10 @@ void failsClosedWhenAuthorizationKeepsChangingDuringRead() { verify(audit, never()).record(any()); } - private static ResolvedKnowledgeEvidenceScope scope( + private static VerifiedGraphEvidenceScope scope( Set assetIds, long generation) { - return new ResolvedKnowledgeEvidenceScope( + return new VerifiedGraphEvidenceScope( ORGANIZATION_ID, USER_ID, null, diff --git a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java index 044b6b69..ce64425b 100644 --- a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java +++ b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java @@ -1,9 +1,8 @@ package com.orgmemory.core.knowledge.graph; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeResolver; -import com.orgmemory.core.knowledge.retrieval.KnowledgeEvidenceScopeUnavailableException; +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; -import com.orgmemory.core.knowledge.retrieval.ResolvedKnowledgeEvidenceScope; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.knowledge.space.KnowledgeSpaceQuery; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -44,8 +43,8 @@ class KnowledgeGraphExportServiceTests { private final KnowledgeSpaceQuery spaces = mock(KnowledgeSpaceQuery.class); private final RelationshipAuthorizationPort authorization = mock(RelationshipAuthorizationPort.class); - private final KnowledgeEvidenceScopeResolver evidenceScopes = - mock(KnowledgeEvidenceScopeResolver.class); + private final GraphEvidenceVerifier evidenceVerifier = + mock(GraphEvidenceVerifier.class); private final GraphExportReader reader = mock(GraphExportReader.class); private final PermissionAuditService audit = mock(PermissionAuditService.class); @@ -55,7 +54,7 @@ class KnowledgeGraphExportServiceTests { new KnowledgeGraphExportService( spaces, authorization, - evidenceScopes, + evidenceVerifier, reader, audit); @@ -65,8 +64,8 @@ void setUpEntryPermission() { .thenReturn(true); when(authorization.check(any())) .thenReturn(AuthorizationDecision.allow("model-v1")); - when(evidenceScopes.resolve(actor, "model-v1")).thenReturn( - new ResolvedKnowledgeEvidenceScope( + when(evidenceVerifier.verifyScope(actor, "model-v1")).thenReturn( + new VerifiedGraphEvidenceScope( ORGANIZATION_ID, USER_ID, null, @@ -101,10 +100,9 @@ void exportsOnlyTheCurrentAuthorizedEvidenceScopeAndAuditsEgress() { @Test void reportsUnavailableForUnexpectedOpenFgaObjectTypesBeforeReadingGraphData() { - when(evidenceScopes.resolve(actor, "model-v1")).thenThrow( - new KnowledgeEvidenceScopeUnavailableException( - "AUTHORIZED_OBJECT_SET_INVALID", - "model-v1")); + when(evidenceVerifier.verifyScope(actor, "model-v1")).thenThrow( + new KnowledgeRetrievalUnavailableException( + "Canonical Graph evidence scope is unavailable")); assertThrows( KnowledgeRetrievalUnavailableException.class, diff --git a/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java b/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java new file mode 100644 index 00000000..cc6d3ca8 --- /dev/null +++ b/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java @@ -0,0 +1,264 @@ +package com.orgmemory.core.knowledge.retrieval; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.orgmemory.core.organization.CurrentActor; +import com.orgmemory.graphrag.model.EvidenceReference; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class CanonicalGraphEvidenceVerifierTests { + + private static final UUID ORGANIZATION_ID = UUID.randomUUID(); + private static final UUID USER_ID = UUID.randomUUID(); + private static final UUID SPACE_ID = UUID.randomUUID(); + private static final UUID ASSET_ID = UUID.randomUUID(); + private static final UUID SOURCE_OBJECT_ID = UUID.randomUUID(); + private static final UUID REVISION_ID = UUID.randomUUID(); + private static final UUID ACL_ID = UUID.randomUUID(); + private static final UUID CHUNK_ID = UUID.randomUUID(); + private static final UUID PROFILE_ID = UUID.randomUUID(); + private static final Instant EVALUATED_AT = + Instant.parse("2026-08-02T00:00:00Z"); + + private final KnowledgeEvidenceScopeResolver evidenceScopes = + mock(KnowledgeEvidenceScopeResolver.class); + private final SecureKnowledgeRetrievalStore canonicalEvidence = + mock(SecureKnowledgeRetrievalStore.class); + private final CanonicalGraphEvidenceVerifier verifier = + new CanonicalGraphEvidenceVerifier(evidenceScopes, canonicalEvidence); + private final CurrentActor actor = + new CurrentActor(USER_ID, ORGANIZATION_ID, null, "User", "user@example.com"); + + @BeforeEach + void setUpScope() { + when(evidenceScopes.resolve(actor, "model-v1")) + .thenReturn(resolvedScope()); + } + + @Test + void exposesAnImmutableGraphSnapshotWithoutLeakingTheInternalScope() { + VerifiedGraphEvidenceScope result = + verifier.verifyScope(actor, "model-v1"); + + assertEquals(ORGANIZATION_ID, result.organizationId()); + assertEquals(USER_ID, result.actorUserId()); + assertEquals("model-v1", result.authorizationModelId()); + assertEquals(EVALUATED_AT, result.evaluatedAt()); + assertEquals(Set.of(ASSET_ID), result.assetIdsByKnowledgeSpace().get(SPACE_ID)); + assertEquals(7L, result.authorizationGeneration(SPACE_ID)); + assertThrows( + UnsupportedOperationException.class, + () -> result.assetIdsByKnowledgeSpace().put(UUID.randomUUID(), Set.of())); + assertThrows( + UnsupportedOperationException.class, + () -> result.assetIdsByKnowledgeSpace().get(SPACE_ID).add(UUID.randomUUID())); + } + + @Test + void translatesScopeResolutionFailureToTheStableRetrievalException() { + var cause = new KnowledgeEvidenceScopeUnavailableException( + "AUTHORIZED_OBJECT_SET_INVALID", "model-v1"); + when(evidenceScopes.resolve(actor, "model-v1")).thenThrow(cause); + + KnowledgeRetrievalUnavailableException thrown = assertThrows( + KnowledgeRetrievalUnavailableException.class, + () -> verifier.verifyScope(actor, "model-v1")); + + assertSame(cause, thrown.getCause()); + } + + @Test + void acceptsOnlyTheExactCurrentCanonicalEvidenceIdentity() { + VerifiedGraphEvidenceScope scope = verifier.verifyScope(actor, "model-v1"); + EvidenceReference evidence = evidence(); + when(canonicalEvidence.recheck(any(), any())) + .thenReturn(List.of(candidate( + ORGANIZATION_ID, + CHUNK_ID, + ASSET_ID, + REVISION_ID, + ACL_ID))); + + assertTrue(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + + ArgumentCaptor retrievalScope = + ArgumentCaptor.forClass(SecureKnowledgeRetrievalStore.RetrievalScope.class); + verify(canonicalEvidence).recheck(retrievalScope.capture(), eq(List.of(CHUNK_ID))); + assertEquals(ORGANIZATION_ID, retrievalScope.getValue().organizationId()); + assertEquals(List.of(ASSET_ID), retrievalScope.getValue().authorizedAssetIds()); + assertEquals("model-v1", retrievalScope.getValue().authorizationModelId()); + } + + @Test + void rejectsEvidenceOutsideTheVerifiedSpaceWithoutTouchingTheStore() { + VerifiedGraphEvidenceScope scope = verifier.verifyScope(actor, "model-v1"); + + assertFalse(verifier.isCurrentGoverningEvidence( + scope, UUID.randomUUID(), evidence())); + assertFalse(verifier.isCurrentGoverningEvidence( + scope, + SPACE_ID, + new EvidenceReference( + ORGANIZATION_ID, + UUID.randomUUID(), + REVISION_ID, + CHUNK_ID, + ACL_ID, + 7))); + + verify(canonicalEvidence, never()).recheck(any(), any()); + } + + @Test + void rejectsMissingDuplicateOrMismatchedCanonicalCandidates() { + VerifiedGraphEvidenceScope scope = verifier.verifyScope(actor, "model-v1"); + EvidenceReference evidence = evidence(); + SecureRetrievalCandidate exact = candidate( + ORGANIZATION_ID, + CHUNK_ID, + ASSET_ID, + REVISION_ID, + ACL_ID); + when(canonicalEvidence.recheck(any(), any())) + .thenReturn( + List.of(), + List.of(exact, exact), + List.of(candidate( + UUID.randomUUID(), + CHUNK_ID, + ASSET_ID, + REVISION_ID, + ACL_ID)), + List.of(candidate( + ORGANIZATION_ID, + UUID.randomUUID(), + ASSET_ID, + REVISION_ID, + ACL_ID)), + List.of(candidate( + ORGANIZATION_ID, + CHUNK_ID, + ASSET_ID, + UUID.randomUUID(), + ACL_ID)), + List.of(candidate( + ORGANIZATION_ID, + CHUNK_ID, + ASSET_ID, + REVISION_ID, + UUID.randomUUID()))); + + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + } + + @Test + void comparesGraphSnapshotsUsingTheCallersExistingSemantics() { + VerifiedGraphEvidenceScope initial = verifier.verifyScope(actor, "model-v1"); + VerifiedGraphEvidenceScope same = snapshot("model-v1", Set.of(ASSET_ID), 7L); + VerifiedGraphEvidenceScope newGeneration = snapshot("model-v1", Set.of(ASSET_ID), 8L); + VerifiedGraphEvidenceScope newModel = snapshot("model-v2", Set.of(ASSET_ID), 7L); + + assertTrue(initial.hasSameAuthorizationFingerprint(same, SPACE_ID)); + assertTrue(initial.hasSameAssetsAndGeneration(same, SPACE_ID)); + assertFalse(initial.hasSameAuthorizationFingerprint(newGeneration, SPACE_ID)); + assertFalse(initial.hasSameAssetsAndGeneration(newGeneration, SPACE_ID)); + assertFalse(initial.hasSameAuthorizationFingerprint(newModel, SPACE_ID)); + assertTrue(initial.hasSameAssetsAndGeneration(newModel, SPACE_ID)); + assertThrows( + IllegalArgumentException.class, + () -> new VerifiedGraphEvidenceScope( + ORGANIZATION_ID, + USER_ID, + null, + false, + "model-v1", + EVALUATED_AT, + Map.of(SPACE_ID, Set.of(ASSET_ID)), + Map.of())); + } + + private static ResolvedKnowledgeEvidenceScope resolvedScope() { + return new ResolvedKnowledgeEvidenceScope( + ORGANIZATION_ID, + USER_ID, + null, + false, + "model-v1", + EVALUATED_AT, + Map.of(SPACE_ID, Set.of(ASSET_ID)), + Map.of(SPACE_ID, 7L)); + } + + private static VerifiedGraphEvidenceScope snapshot( + String authorizationModelId, + Set assets, + long generation) { + return new VerifiedGraphEvidenceScope( + ORGANIZATION_ID, + USER_ID, + null, + false, + authorizationModelId, + EVALUATED_AT, + Map.of(SPACE_ID, assets), + Map.of(SPACE_ID, generation)); + } + + private static EvidenceReference evidence() { + return new EvidenceReference( + ORGANIZATION_ID, + ASSET_ID, + REVISION_ID, + CHUNK_ID, + ACL_ID, + 7); + } + + private static SecureRetrievalCandidate candidate( + UUID organizationId, + UUID chunkId, + UUID assetId, + UUID revisionId, + UUID currentAclSnapshotId) { + return new SecureRetrievalCandidate( + organizationId, + chunkId, + assetId, + SOURCE_OBJECT_ID, + revisionId, + "Policy", + "Approved policy", + "source://policy", + null, + null, + null, + 0, + currentAclSnapshotId, + currentAclSnapshotId, + "model-v1", + PROFILE_ID, + 1); + } +} diff --git a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md index feab5853..97ce16a8 100644 --- a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md +++ b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md @@ -1171,7 +1171,7 @@ unaffected jobs skipped by surface detection. CodeRabbit was rate limited; direct audit found no defect, review, inline comment, or review thread. Both the PR head `b5428d33` and merge commit are ancestors of current `origin/main`. -## Current Pull Request Gates +## Thirty-ninth Pull Request Evidence - Source Ledger owns one typed citation-evidence query that resolves a tenant-scoped ready revision, matching Knowledge Asset, and validated evidence @@ -1207,3 +1207,49 @@ After merging current `origin/main` at `39281c33`, the Citation plus full Modulith slice passed again in 55s. The documentation check passed across the new base's 485 Markdown files and 8 mirrored domain pairs, and all 41 release-policy tests passed again on Node 24.15.0. + +PR #258 merged as `6ed738c2` after Backend Java 25, documentation, evaluation, +secret, impact, release-preview, release-policy, and aggregate CI checks passed; +unaffected jobs skipped by surface detection. CodeRabbit was rate limited; +direct audit against the architecture verdict found no defect, review, inline +comment, or review thread. Both the PR head `815640cd` and merge commit are +ancestors of current `origin/main`. + +## Current Pull Request Gates + +- Retrieval owns one `GraphEvidenceVerifier` contract and immutable + `VerifiedGraphEvidenceScope`; its package-private implementation alone may use + `KnowledgeEvidenceScopeResolver`, `ResolvedKnowledgeEvidenceScope`, + `SecureKnowledgeRetrievalStore`, its retrieval scope, or + `SecureRetrievalCandidate`. +- Graph exploration and export use verified per-Space evidence snapshots and + retain their existing before/after authorization comparison and retry/fail + behavior. Curation uses the verifier for governing chunk freshness and retains + its stricter authorized-asset plus ACL-generation comparison. +- Graph imports no Retrieval resolver, resolved scope, store, store scope, or + candidate. Its remaining Retrieval dependencies are the verifier/snapshot, + existing retrieval-unavailable exception, and embedding profile contracts. +- Current authorization remains resolved before Graph reads; governing evidence + must still match organization, Asset, revision, ACL snapshot, and chunk after a + canonical store recheck. +- This code PR remains below 100 changed paths. Retrieval stays open for the + remaining API/Worker adapter interfaces and final closure. + +Local verification starts by changing the exact Graph-to-Retrieval dependency +test to the intended verifier-only surface and observing it fail against the +current resolver/store/candidate imports. The verifier, all three Graph use +cases, and both exact Modulith guards then passed their focused slice. The first +full Core run exposed the second temporary-open-boundary allowlist that still +named the retired Graph dependencies; after aligning that guard, the two +structural tests and full Core rerun passed. That first run also exhausted native +JVM memory while two unrelated worktrees were running Gradle concurrently; the +isolated sequential rerun passed in 2m10s. Full API and Worker reruns passed in +5m and 2m36s, including deployable Spring wiring. The documentation +operating-model check passed for 486 Markdown files and 8 mirrored domain pairs. +Release policy passed all 41 tests on Node 24.15.0. The mechanical audit found +20 changed paths, no migration, no empty changed file, no forbidden Graph import +of Retrieval implementation types, and a clean whitespace diff. The terminating +sequential `clean test` passed with 99 actionable tasks in 2m05s; after the +verifier test was strengthened to cover organization and chunk mismatches, its +focused rerun stayed green and a fresh terminating `clean test` passed all 99 +tasks again in 1m41s. diff --git a/docs/specs/domains/secure-graph-rag.md b/docs/specs/domains/secure-graph-rag.md index 1050a59e..76d6df65 100644 --- a/docs/specs/domains/secure-graph-rag.md +++ b/docs/specs/domains/secure-graph-rag.md @@ -8,7 +8,7 @@ payload-boundary configuration this document states — `apps/api/src/main/resources/application*.yml` and `apps/worker/src/main/resources/application*.yml`. -Reconciled: `2026-08-02-graph-extraction-model-route (aca7eede)`. +Reconciled: `2026-08-02-spring-modulith-package-refactor (6ed738c2)`. ## Current Contract @@ -21,6 +21,11 @@ Reconciled: `2026-08-02-graph-extraction-model-route (aca7eede)`. - Every graph read requires an `AuthorizedEvidenceScope`; ranking, adjacency, degree, weight, aggregation, and citations can use only visible contributions. +- Graph exploration, export, and curation obtain the canonical authorization + snapshot through Retrieval's `GraphEvidenceVerifier`. Only its immutable + `VerifiedGraphEvidenceScope` crosses the module boundary. Retrieval alone + resolves the scope and rechecks an exact governing chunk against the current + canonical organization, Asset, revision, and ACL identity. - `SECURE_MIX` is the product default. Strategy selection remains internal. - Query results preserve structured entity, relation, and chunk selections. Entity and relation descriptions retain their individual chunk evidence; diff --git a/docs/specs/domains/secure-retrieval.md b/docs/specs/domains/secure-retrieval.md index 7e7a3697..247a3a77 100644 --- a/docs/specs/domains/secure-retrieval.md +++ b/docs/specs/domains/secure-retrieval.md @@ -5,7 +5,7 @@ Source: `core/src/main/java/com/orgmemory/core/knowledge`, `apps/api/src/main/java/com/orgmemory/api/knowledge`, and `integrations/authorization-openfga`. -Reconciled: `2026-08-01-spring-modulith-package-refactor (7cef296c)`. +Reconciled: `2026-08-02-spring-modulith-package-refactor (6ed738c2)`. ## Current Behavior @@ -40,6 +40,13 @@ state through Organization-owned queries before resolving evidence or source visibility. It does not trust those actor fields as authorization facts and imports no Organization entity, role, or repository. +Graph exploration, export, and curation cross the Retrieval-owned +`GraphEvidenceVerifier` and immutable `VerifiedGraphEvidenceScope`. The +package-private implementation alone resolves canonical authorization state +and rechecks governing evidence through the secure retrieval store. Graph +imports neither the scope resolver, internal resolved scope, store, nor secure +candidate representation. + Citation URLs are opaque API routes, not object-storage URLs. Opening one reruns the current canonical evidence boundary once, validates the revision and blob integrity, and streams the original bytes through the authenticated API with diff --git a/docs/tests/domains/secure-graph-rag.md b/docs/tests/domains/secure-graph-rag.md index 5956cd83..380b44c9 100644 --- a/docs/tests/domains/secure-graph-rag.md +++ b/docs/tests/domains/secure-graph-rag.md @@ -9,7 +9,7 @@ Source: `components/graph-rag-core/src/test`, `core/src/test/java/com/orgmemory/core/knowledge`, and `apps/web/test/e2e`. -Reconciled: `2026-08-02-graph-extraction-model-route (aca7eede)`. +Reconciled: `2026-08-02-spring-modulith-package-refactor (6ed738c2)`. ## Automated @@ -20,6 +20,10 @@ Reconciled: `2026-08-02-graph-extraction-model-route (aca7eede)`. explicitly overridable. Production Compose validation checks the same route. - Graph-testkit security tests prove permission-scoped contribution, adjacency, degree, weight, seed, replacement, and removal behavior. +- Core verifier and Graph use-case tests prove immutable authorized snapshots, + unavailable-scope translation, exact current governing-evidence identity, + before/after authorization checks, and no Graph dependency on Retrieval's + resolver, resolved scope, store, or candidate implementation types. - Core traversal tests prove exact-snapshot validation before zero/empty returns, authorized seed normalization, multi-page completion, canonical UUID ordering, one global limit, cycles, disconnected nodes, seed permutations, diff --git a/docs/tests/domains/secure-retrieval.md b/docs/tests/domains/secure-retrieval.md index c921e404..3a1bd49c 100644 --- a/docs/tests/domains/secure-retrieval.md +++ b/docs/tests/domains/secure-retrieval.md @@ -5,7 +5,7 @@ Source: `core/src/test/java/com/orgmemory/core/knowledge`, `apps/api/src/test/java/com/orgmemory/api/knowledge`, and `integrations/authorization-openfga/src/test`. -Reconciled: `2026-08-01-spring-modulith-package-refactor (7cef296c)`. +Reconciled: `2026-08-02-spring-modulith-package-refactor (6ed738c2)`. Primary evidence: `apps/api/src/test/java/com/orgmemory/api/knowledge/KnowledgeRetrievalIntegrationTests.java` and `core/src/test/java/com/orgmemory/core/permission/KnowledgePermissionPolicyTests.java`. @@ -26,6 +26,7 @@ Primary evidence: `apps/api/src/test/java/com/orgmemory/api/knowledge/KnowledgeR | Retrieval crosses one Asset-owned query for existence, active authorization scopes, and current catalog projection without importing Asset repositories | `JpaKnowledgeAssetRetrievalQueryTests`, `ModulithVerificationTests#retrievalDoesNotDependOnAssetRepositories`, `#retrievalAssetReadsUseOnlyTheOwnerQuery`, `ExternalPrincipalRetrievalIntegrationTests`, `PermissionsAdminIntegrationTests` | | Retrieval reloads active persisted department and Executive facts through Organization-owned queries and imports no Organization persistence or role types | `JpaKnowledgeAccessSubjectQueryTests`, `JpaOrganizationResourceQueryTests`, `ModulithVerificationTests#retrievalDoesNotDependOnOrganizationPersistenceOrRoleTypes`, `#retrievalOrganizationReadsUseOnlyOwnerQueries`, `SecureSourceVisibilityAdapterTests`, `KnowledgeRetrievalIntegrationTests` | | Citation opening consumes Source Ledger-owned immutable evidence without revision/blob persistence leakage, preserves typed unavailable audit reasons, and closes integrity-mismatched content before an allow audit | `SourceCitationEvidenceQueryTests`, `CitationContentServiceTests`, `ModulithVerificationTests#retrievalDoesNotDependOnSourceLedgerCitationPersistenceOrStatusTypes`, `#citationContentUsesTheSourceLedgerOwnerQuery` | +| Graph exploration, export, and curation consume only Retrieval's verifier and immutable snapshot; the verifier maps canonical scope failures and accepts only one exact current governing-evidence candidate | `CanonicalGraphEvidenceVerifierTests`, `KnowledgeGraphExplorerServiceTests`, `KnowledgeGraphExportServiceTests`, `KnowledgeGraphCurationServiceTests`, `ModulithVerificationTests#graphConsumesOnlyRetrievalGraphContracts` | Request-boundary missing control role/incomplete actor returns `403`; generic resource `404` does not claim otherwise. Provider-backed evaluation, From 80a5b7cdc8711bf276df0ef437476fbfb4f02002 Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Sun, 2 Aug 2026 10:12:14 +0700 Subject: [PATCH 2/4] docs(increment): record release disposition gate --- .../2026-07-31-spring-modulith-package-refactor/plan.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md index 3ae5ec1e..d82cbce2 100644 --- a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md +++ b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md @@ -1259,3 +1259,9 @@ classes plus the full Modulith verification slice passed in 1m35s. The documentation operating-model check passed on the merged base for 501 Markdown files and 8 mirrored domain pairs, and all 41 release-policy tests passed again on Node 24.15.0. + +PR CI's first product-release job passed its contract tests but rejected the +missing release disposition in the PR event payload. The PR now explicitly +skips an intermediate release because the project owner requested one release +only after the full refactor goal; a new synchronize event is required because +rerunning the original workflow retains its original PR payload. From ebd3bba9c3ceeef0e54f5954b3a4063a0110c7cf Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Sun, 2 Aug 2026 10:31:36 +0700 Subject: [PATCH 3/4] fix(knowledge): fail closed on unknown graph scopes --- ARCHITECTURE.md | 4 +- .../graph/GraphEvidenceScopeAccess.java | 25 ++++++++ .../graph/KnowledgeGraphCurationService.java | 17 +++--- .../graph/KnowledgeGraphExplorerService.java | 21 ++----- .../graph/KnowledgeGraphExportService.java | 39 +++++------- .../CanonicalGraphEvidenceVerifier.java | 2 +- .../retrieval/VerifiedGraphEvidenceScope.java | 61 +++++++++++++------ .../core/ModulithVerificationTests.java | 1 + .../KnowledgeGraphCurationServiceTests.java | 54 ++++++++++++++++ .../KnowledgeGraphExportServiceTests.java | 23 +++++++ .../CanonicalGraphEvidenceVerifierTests.java | 42 ++++++++++++- .../plan.md | 14 +++++ docs/specs/domains/secure-graph-rag.md | 4 +- docs/specs/domains/secure-retrieval.md | 4 +- docs/tests/domains/secure-graph-rag.md | 6 +- docs/tests/domains/secure-retrieval.md | 2 +- 16 files changed, 245 insertions(+), 74 deletions(-) create mode 100644 core/src/main/java/com/orgmemory/core/knowledge/graph/GraphEvidenceScopeAccess.java diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 46868f5f..6da4e9cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,7 +167,9 @@ consume that parent interface without importing Retrieval implementation types. Graph exploration, export, and curation obtain their immutable authorized evidence snapshot and exact current governing-evidence decision through the Retrieval-owned `GraphEvidenceVerifier`; Graph does not import Retrieval scope -resolution, candidate, or store implementation types. Retrieval remains +resolution, candidate, or store implementation types. Verified snapshots reject +unknown Knowledge Spaces, and canonical evidence rechecks carry only the assets +authorized for the requested Space. Retrieval remains explicitly open while its remaining sibling adapters are replaced by intentional APIs. The provider-neutral object-storage port is exposed as the diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/GraphEvidenceScopeAccess.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/GraphEvidenceScopeAccess.java new file mode 100644 index 00000000..8e4b2ad6 --- /dev/null +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/GraphEvidenceScopeAccess.java @@ -0,0 +1,25 @@ +package com.orgmemory.core.knowledge.graph; + +import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; +import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; +import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; +import com.orgmemory.core.organization.CurrentActor; + +/** Shared Graph-side translation for unavailable canonical evidence scopes. */ +final class GraphEvidenceScopeAccess { + + private GraphEvidenceScopeAccess() {} + + static VerifiedGraphEvidenceScope verify( + GraphEvidenceVerifier verifier, + CurrentActor actor, + String authorizationModelId, + String unavailableMessage) { + try { + return verifier.verifyScope(actor, authorizationModelId); + } catch (KnowledgeRetrievalUnavailableException unavailable) { + throw new KnowledgeRetrievalUnavailableException( + unavailableMessage, unavailable); + } + } +} diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java index 69048488..99fd1e55 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationService.java @@ -195,8 +195,9 @@ public void deactivate( requirePermission(actor, knowledgeSpaceId); VerifiedGraphEvidenceScope resolved = resolve(actor, decision.policyVersion()); - if (resolved.authorizationGeneration(knowledgeSpaceId) - != authorizationGeneration) { + if (!resolved.includesKnowledgeSpace(knowledgeSpaceId) + || resolved.authorizationGeneration(knowledgeSpaceId) + != authorizationGeneration) { throw new KnowledgeRetrievalUnavailableException( "Knowledge graph authorization changed before curation"); } @@ -291,13 +292,11 @@ private void requireVisibleIdentity( private VerifiedGraphEvidenceScope resolve( CurrentActor actor, String authorizationModelId) { - try { - return evidenceVerifier.verifyScope(actor, authorizationModelId); - } catch (KnowledgeRetrievalUnavailableException unavailable) { - throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions are temporarily unavailable", - unavailable); - } + return GraphEvidenceScopeAccess.verify( + evidenceVerifier, + actor, + authorizationModelId, + "Knowledge graph permissions are temporarily unavailable"); } private void requireUnchangedScope( diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java index 6d69ea93..16b58f75 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExplorerService.java @@ -115,7 +115,7 @@ private KnowledgeGraphView explore( VerifiedGraphEvidenceScope current = resolve(actor, policyVersion); - if (!sameSpaceScope(initial, current, knowledgeSpaceId)) { + if (!initial.hasSameSpaceScope(current, knowledgeSpaceId)) { if (attempt == 0) { return explore( actor, @@ -192,13 +192,11 @@ private String requireSpaceAccess( private VerifiedGraphEvidenceScope resolve( CurrentActor actor, String policyVersion) { - try { - return evidenceVerifier.verifyScope(actor, policyVersion); - } catch (KnowledgeRetrievalUnavailableException unavailable) { - throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions are temporarily unavailable", - unavailable); - } + return GraphEvidenceScopeAccess.verify( + evidenceVerifier, + actor, + policyVersion, + "Knowledge graph permissions are temporarily unavailable"); } private KnowledgeGraphView empty( @@ -405,13 +403,6 @@ private static boolean contains(String value, String needle) { return value.toLowerCase(Locale.ROOT).contains(needle); } - private static boolean sameSpaceScope( - VerifiedGraphEvidenceScope initial, - VerifiedGraphEvidenceScope current, - UUID knowledgeSpaceId) { - return initial.hasSameAuthorizationFingerprint(current, knowledgeSpaceId); - } - private String normalizeQuery(String query) { if (query == null || query.isBlank()) { return ""; diff --git a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java index fdb440a4..ab17d632 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportService.java @@ -74,30 +74,19 @@ public GraphExportFormatter.Artifact export( if (!entry.allowed()) { throw accessDenied(); } - VerifiedGraphEvidenceScope resolved; - try { - resolved = evidenceVerifier.verifyScope( - actor, entry.policyVersion()); - } catch (KnowledgeRetrievalUnavailableException unavailable) { - throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions changed while preparing the export", - unavailable); + VerifiedGraphEvidenceScope resolved = + resolve(actor, entry.policyVersion()); + if (!resolved.includesKnowledgeSpace(knowledgeSpaceId)) { + throw accessDenied(); } ProjectionNamespace namespace = KnowledgeProjectionNamespaces.forSpace( actor.organizationId(), knowledgeSpaceId); var document = reader.read( resolved.forKnowledgeSpace(knowledgeSpaceId), namespace); - VerifiedGraphEvidenceScope current; - try { - current = evidenceVerifier.verifyScope( - actor, entry.policyVersion()); - } catch (KnowledgeRetrievalUnavailableException unavailable) { - throw new KnowledgeRetrievalUnavailableException( - "Knowledge graph permissions changed while preparing the export", - unavailable); - } - if (!sameSpaceScope(resolved, current, knowledgeSpaceId)) { + VerifiedGraphEvidenceScope current = + resolve(actor, entry.policyVersion()); + if (!resolved.hasSameSpaceScope(current, knowledgeSpaceId)) { throw new KnowledgeRetrievalUnavailableException( "Knowledge graph permissions changed while preparing the export"); } @@ -117,12 +106,14 @@ public GraphExportFormatter.Artifact export( return artifact; } - private static boolean sameSpaceScope( - VerifiedGraphEvidenceScope first, - VerifiedGraphEvidenceScope second, - UUID knowledgeSpaceId) { - return first.authorizationModelId().equals(second.authorizationModelId()) - && first.hasSameAssetsAndGeneration(second, knowledgeSpaceId); + private VerifiedGraphEvidenceScope resolve( + CurrentActor actor, + String authorizationModelId) { + return GraphEvidenceScopeAccess.verify( + evidenceVerifier, + actor, + authorizationModelId, + "Knowledge graph permissions changed while preparing the export"); } private static OrgMemoryAccessDeniedException accessDenied() { diff --git a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java index d2e799f5..923a6270 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifier.java @@ -61,7 +61,7 @@ public boolean isCurrentGoverningEvidence( return false; } var candidates = canonicalEvidence.recheck( - scope.toRetrievalScope(), + scope.toRetrievalScope(knowledgeSpaceId), List.of(Objects.requireNonNull( evidence.chunkId(), "governing evidence chunkId"))); return candidates.size() == 1 diff --git a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java index 5a3fe7ec..f97e8373 100644 --- a/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java +++ b/core/src/main/java/com/orgmemory/core/knowledge/retrieval/VerifiedGraphEvidenceScope.java @@ -3,7 +3,6 @@ import com.orgmemory.graphrag.authorization.AuthorizedEvidenceScope; import java.time.Instant; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -51,45 +50,51 @@ public boolean includes( UUID knowledgeSpaceId, UUID candidateOrganizationId, UUID candidateAssetId) { - return forKnowledgeSpace(knowledgeSpaceId) - .includes(candidateOrganizationId, candidateAssetId); + UUID spaceId = Objects.requireNonNull( + knowledgeSpaceId, "knowledgeSpaceId"); + return organizationId.equals(candidateOrganizationId) + && assetIdsByKnowledgeSpace + .getOrDefault(spaceId, Set.of()) + .contains(candidateAssetId); } public AuthorizedEvidenceScope forKnowledgeSpace(UUID knowledgeSpaceId) { - UUID spaceId = Objects.requireNonNull( - knowledgeSpaceId, "knowledgeSpaceId"); + UUID spaceId = requireKnowledgeSpace(knowledgeSpaceId); return new AuthorizedEvidenceScope( organizationId, actorUserId, actorDepartmentId, actorExecutive, - assetIdsByKnowledgeSpace.getOrDefault(spaceId, Set.of()), + assetIdsByKnowledgeSpace.get(spaceId), authorizationModelId, authorizationGeneration(spaceId), evaluatedAt); } public long authorizationGeneration(UUID knowledgeSpaceId) { - return aclGenerationByKnowledgeSpace.getOrDefault( - Objects.requireNonNull(knowledgeSpaceId, "knowledgeSpaceId"), - 0L); + return aclGenerationByKnowledgeSpace.get( + requireKnowledgeSpace(knowledgeSpaceId)); } public boolean hasSameAuthorizationFingerprint( VerifiedGraphEvidenceScope other, UUID knowledgeSpaceId) { Objects.requireNonNull(other, "other"); - return forKnowledgeSpace(knowledgeSpaceId) - .authorizationFingerprint() - .equals(other.forKnowledgeSpace(knowledgeSpaceId) - .authorizationFingerprint()); + return includesKnowledgeSpace(knowledgeSpaceId) + && other.includesKnowledgeSpace(knowledgeSpaceId) + && forKnowledgeSpace(knowledgeSpaceId) + .authorizationFingerprint() + .equals(other.forKnowledgeSpace(knowledgeSpaceId) + .authorizationFingerprint()); } public boolean hasSameAssetsAndGeneration( VerifiedGraphEvidenceScope other, UUID knowledgeSpaceId) { Objects.requireNonNull(other, "other"); - return forKnowledgeSpace(knowledgeSpaceId) + return includesKnowledgeSpace(knowledgeSpaceId) + && other.includesKnowledgeSpace(knowledgeSpaceId) + && forKnowledgeSpace(knowledgeSpaceId) .authorizedAssetIds() .equals(other.forKnowledgeSpace(knowledgeSpaceId) .authorizedAssetIds()) @@ -97,19 +102,39 @@ && authorizationGeneration(knowledgeSpaceId) == other.authorizationGeneration(knowledgeSpaceId); } - SecureKnowledgeRetrievalStore.RetrievalScope toRetrievalScope() { - LinkedHashSet assetIds = new LinkedHashSet<>(); - assetIdsByKnowledgeSpace.values().forEach(assetIds::addAll); + public boolean hasSameSpaceScope( + VerifiedGraphEvidenceScope other, + UUID knowledgeSpaceId) { + Objects.requireNonNull(other, "other"); + return authorizationModelId.equals(other.authorizationModelId()) + && hasSameAssetsAndGeneration(other, knowledgeSpaceId); + } + + SecureKnowledgeRetrievalStore.RetrievalScope toRetrievalScope( + UUID knowledgeSpaceId) { + UUID spaceId = requireKnowledgeSpace(knowledgeSpaceId); return new SecureKnowledgeRetrievalStore.RetrievalScope( organizationId, actorUserId, actorDepartmentId, actorExecutive, - assetIds.stream().sorted().toList(), + assetIdsByKnowledgeSpace.get(spaceId).stream() + .sorted() + .toList(), authorizationModelId, evaluatedAt); } + private UUID requireKnowledgeSpace(UUID knowledgeSpaceId) { + UUID spaceId = Objects.requireNonNull( + knowledgeSpaceId, "knowledgeSpaceId"); + if (!assetIdsByKnowledgeSpace.containsKey(spaceId)) { + throw new IllegalArgumentException( + "Knowledge Space is not part of the verified scope"); + } + return spaceId; + } + private static Map> immutableSets( Map> source) { Map> copy = new LinkedHashMap<>(); diff --git a/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java b/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java index e9f460c8..c0e8db56 100644 --- a/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java +++ b/core/src/test/java/com/orgmemory/core/ModulithVerificationTests.java @@ -714,6 +714,7 @@ void knowledgeRetrievalTemporaryOpenBoundaryDoesNotGainNewConsumers() { "com.orgmemory.core.knowledge.connector.ConnectorReconciler", "com.orgmemory.core.knowledge.connector.ConnectorSourceRevisionCoordinator", "com.orgmemory.core.knowledge.graph.ClaimedGraphIndex", + "com.orgmemory.core.knowledge.graph.GraphEvidenceScopeAccess", "com.orgmemory.core.knowledge.graph.GraphIndexingCoordinator", "com.orgmemory.core.knowledge.graph.KnowledgeGraphCurationService", "com.orgmemory.core.knowledge.graph.KnowledgeGraphExplorerConfiguration", diff --git a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java index 066c7201..ff66a665 100644 --- a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java +++ b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphCurationServiceTests.java @@ -1,6 +1,7 @@ package com.orgmemory.core.knowledge.graph; import com.orgmemory.core.knowledge.retrieval.GraphEvidenceVerifier; +import com.orgmemory.core.knowledge.retrieval.KnowledgeRetrievalUnavailableException; import com.orgmemory.core.shared.error.KnowledgeResourceNotFoundException; import com.orgmemory.core.knowledge.retrieval.VerifiedGraphEvidenceScope; import com.orgmemory.core.knowledge.asset.KnowledgeAssetGraphQuery; @@ -161,6 +162,59 @@ void governingEvidenceCannotCrossKnowledgeSpaces() { verify(store, never()).append(any(), any()); } + @Test + void staleGoverningEvidenceFailsClosedBeforeTheLedger() { + when(authorization.check(any())) + .thenReturn(AuthorizationDecision.allow("model-v1")); + when(evidenceVerifier.isCurrentGoverningEvidence(any(), any(), any())) + .thenReturn(false); + + OrgMemoryAccessDeniedException thrown = assertThrows( + OrgMemoryAccessDeniedException.class, + () -> service.apply( + actor, + new KnowledgeGraphCurationCommand.CurateEntity( + SPACE_ID, + "curation-1", + "attempt", + 7, + ENTITY_ID, + "Policy", + "POLICY", + "Stale", + evidence()))); + + assertEquals("Governing evidence is stale or unavailable", thrown.getMessage()); + verify(store, never()).append(any(), any()); + } + + @Test + void deactivateFailsClosedWhenTheVerifiedScopeDoesNotIncludeTheSpace() { + when(authorization.check(any())) + .thenReturn(AuthorizationDecision.allow("model-v1")); + when(evidenceVerifier.verifyScope(actor, "model-v1")) + .thenReturn(new VerifiedGraphEvidenceScope( + ORGANIZATION_ID, + USER_ID, + null, + false, + "model-v1", + Instant.parse("2026-07-24T00:00:00Z"), + Map.of(), + Map.of())); + + assertThrows( + KnowledgeRetrievalUnavailableException.class, + () -> service.deactivate( + actor, + SPACE_ID, + UUID.randomUUID(), + 0L, + "withdraw")); + + verify(store, never()).deactivate(any(), any(), any()); + } + private static EvidenceReference evidence() { return new EvidenceReference( ORGANIZATION_ID, diff --git a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java index ce64425b..f88a7eb0 100644 --- a/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java +++ b/core/src/test/java/com/orgmemory/core/knowledge/graph/KnowledgeGraphExportServiceTests.java @@ -15,6 +15,7 @@ import com.orgmemory.core.authorization.AuthorizationDecision; import com.orgmemory.core.authorization.RelationshipAuthorizationPort; import com.orgmemory.core.organization.CurrentActor; +import com.orgmemory.core.organization.OrgMemoryAccessDeniedException; import com.orgmemory.core.permission.PermissionAuditService; import com.orgmemory.graphrag.authorization.AuthorizedEvidenceScope; import com.orgmemory.graphrag.export.GraphExportDocument; @@ -113,4 +114,26 @@ void reportsUnavailableForUnexpectedOpenFgaObjectTypesBeforeReadingGraphData() { verify(audit, never()).record(any()); } + @Test + void deniesBeforeReadingWhenTheVerifiedScopeDoesNotIncludeTheSpace() { + when(evidenceVerifier.verifyScope(actor, "model-v1")).thenReturn( + new VerifiedGraphEvidenceScope( + ORGANIZATION_ID, + USER_ID, + null, + false, + "model-v1", + Instant.parse("2026-07-24T00:00:00Z"), + Map.of(), + Map.of())); + + assertThrows( + OrgMemoryAccessDeniedException.class, + () -> service.export( + actor, SPACE_ID, GraphExportFormat.JSON, "request-1")); + + verify(reader, never()).read(any(), any()); + verify(audit, never()).record(any()); + } + } diff --git a/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java b/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java index cc6d3ca8..3f9b0eb6 100644 --- a/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java +++ b/core/src/test/java/com/orgmemory/core/knowledge/retrieval/CanonicalGraphEvidenceVerifierTests.java @@ -163,7 +163,13 @@ void rejectsMissingDuplicateOrMismatchedCanonicalCandidates() { CHUNK_ID, ASSET_ID, REVISION_ID, - UUID.randomUUID()))); + UUID.randomUUID())), + List.of(candidate( + ORGANIZATION_ID, + CHUNK_ID, + UUID.randomUUID(), + REVISION_ID, + ACL_ID))); assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); @@ -171,6 +177,7 @@ void rejectsMissingDuplicateOrMismatchedCanonicalCandidates() { assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); + assertFalse(verifier.isCurrentGoverningEvidence(scope, SPACE_ID, evidence)); } @Test @@ -182,10 +189,20 @@ void comparesGraphSnapshotsUsingTheCallersExistingSemantics() { assertTrue(initial.hasSameAuthorizationFingerprint(same, SPACE_ID)); assertTrue(initial.hasSameAssetsAndGeneration(same, SPACE_ID)); + assertTrue(initial.hasSameSpaceScope(same, SPACE_ID)); assertFalse(initial.hasSameAuthorizationFingerprint(newGeneration, SPACE_ID)); assertFalse(initial.hasSameAssetsAndGeneration(newGeneration, SPACE_ID)); assertFalse(initial.hasSameAuthorizationFingerprint(newModel, SPACE_ID)); assertTrue(initial.hasSameAssetsAndGeneration(newModel, SPACE_ID)); + assertFalse(initial.hasSameSpaceScope(newModel, SPACE_ID)); + UUID unknownSpaceId = UUID.randomUUID(); + assertFalse(initial.hasSameSpaceScope(same, unknownSpaceId)); + assertThrows( + IllegalArgumentException.class, + () -> initial.forKnowledgeSpace(unknownSpaceId)); + assertThrows( + IllegalArgumentException.class, + () -> initial.authorizationGeneration(unknownSpaceId)); assertThrows( IllegalArgumentException.class, () -> new VerifiedGraphEvidenceScope( @@ -199,6 +216,29 @@ void comparesGraphSnapshotsUsingTheCallersExistingSemantics() { Map.of())); } + @Test + void canonicalRecheckScopeContainsOnlyAssetsFromTheRequestedSpace() { + UUID otherSpaceId = UUID.randomUUID(); + UUID otherAssetId = UUID.randomUUID(); + var scope = new VerifiedGraphEvidenceScope( + ORGANIZATION_ID, + USER_ID, + null, + false, + "model-v1", + EVALUATED_AT, + Map.of( + SPACE_ID, Set.of(ASSET_ID), + otherSpaceId, Set.of(otherAssetId)), + Map.of( + SPACE_ID, 7L, + otherSpaceId, 3L)); + + assertEquals( + List.of(ASSET_ID), + scope.toRetrievalScope(SPACE_ID).authorizedAssetIds()); + } + private static ResolvedKnowledgeEvidenceScope resolvedScope() { return new ResolvedKnowledgeEvidenceScope( ORGANIZATION_ID, diff --git a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md index d82cbce2..43069070 100644 --- a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md +++ b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md @@ -1265,3 +1265,17 @@ missing release disposition in the PR event payload. The PR now explicitly skips an intermediate release because the project owner requested one release only after the full refactor goal; a new synchronize event is required because rerunning the original workflow retains its original PR payload. + +CodeRabbit then found a valid fail-closed gap: an absent Space could degrade to +an empty asset set and generation zero, allowing export comparison or +deactivation guards to treat two absent scopes as stable. The fix makes snapshot +accessors reject unknown Spaces, explicitly denies export/deactivation before +read or write, and narrows canonical evidence rechecks to the requested Space's +assets. Candidate identity tests now vary organization, chunk, Asset, revision, +and current ACL independently; curation tests cover stale evidence and absent +Space deactivation. The duplicated Graph-side unavailable-scope translation and +Space comparison rules were consolidated to avoid authorization drift. The new +tests failed first against the permissive/default APIs; the corrected Graph, +verifier, and full Modulith slice passed in 35s, followed by full Core in 1m43s. +The terminating post-review `clean test` then passed all 99 tasks in 6m31s, +including uncached API and Worker tests affected by the Core boundary change. diff --git a/docs/specs/domains/secure-graph-rag.md b/docs/specs/domains/secure-graph-rag.md index 66c08ecf..6b48a076 100644 --- a/docs/specs/domains/secure-graph-rag.md +++ b/docs/specs/domains/secure-graph-rag.md @@ -25,7 +25,9 @@ Reconciled: `2026-08-02-spring-modulith-package-refactor (f2cf3c67)`. snapshot through Retrieval's `GraphEvidenceVerifier`. Only its immutable `VerifiedGraphEvidenceScope` crosses the module boundary. Retrieval alone resolves the scope and rechecks an exact governing chunk against the current - canonical organization, Asset, revision, and ACL identity. + canonical organization, Asset, revision, and ACL identity. Snapshot accessors + reject an unverified Knowledge Space, and the canonical recheck is narrowed + to the assets authorized for that exact Space. - `SECURE_MIX` is the product default. Strategy selection remains internal. - Query results preserve structured entity, relation, and chunk selections. Entity and relation descriptions retain their individual chunk evidence; diff --git a/docs/specs/domains/secure-retrieval.md b/docs/specs/domains/secure-retrieval.md index 6bf7703c..3b453906 100644 --- a/docs/specs/domains/secure-retrieval.md +++ b/docs/specs/domains/secure-retrieval.md @@ -45,7 +45,9 @@ Graph exploration, export, and curation cross the Retrieval-owned package-private implementation alone resolves canonical authorization state and rechecks governing evidence through the secure retrieval store. Graph imports neither the scope resolver, internal resolved scope, store, nor secure -candidate representation. +candidate representation. Unknown Knowledge Spaces are rejected rather than +degrading to an empty/zero scope, and each governing-evidence recheck contains +only the Asset IDs authorized for the requested Space. Citation URLs are opaque API routes, not object-storage URLs. Opening one reruns the current canonical evidence boundary once, validates the revision and blob diff --git a/docs/tests/domains/secure-graph-rag.md b/docs/tests/domains/secure-graph-rag.md index 1a7c1ead..bf8e0a9d 100644 --- a/docs/tests/domains/secure-graph-rag.md +++ b/docs/tests/domains/secure-graph-rag.md @@ -22,8 +22,10 @@ Reconciled: `2026-08-02-spring-modulith-package-refactor (f2cf3c67)`. adjacency, degree, weight, seed, replacement, and removal behavior. - Core verifier and Graph use-case tests prove immutable authorized snapshots, unavailable-scope translation, exact current governing-evidence identity, - before/after authorization checks, and no Graph dependency on Retrieval's - resolver, resolved scope, store, or candidate implementation types. + per-Space canonical recheck scope, rejection of unknown Spaces before export + or deactivation, before/after authorization checks, and no Graph dependency + on Retrieval's resolver, resolved scope, store, or candidate implementation + types. - Core traversal tests prove exact-snapshot validation before zero/empty returns, authorized seed normalization, multi-page completion, canonical UUID ordering, one global limit, cycles, disconnected nodes, seed permutations, diff --git a/docs/tests/domains/secure-retrieval.md b/docs/tests/domains/secure-retrieval.md index ce6cdcaa..06af480d 100644 --- a/docs/tests/domains/secure-retrieval.md +++ b/docs/tests/domains/secure-retrieval.md @@ -27,7 +27,7 @@ Primary evidence: `apps/api/src/test/java/com/orgmemory/api/knowledge/KnowledgeR | Retrieval crosses one Asset-owned query for existence, active authorization scopes, and current catalog projection without importing Asset repositories | `JpaKnowledgeAssetRetrievalQueryTests`, `ModulithVerificationTests#retrievalDoesNotDependOnAssetRepositories`, `#retrievalAssetReadsUseOnlyTheOwnerQuery`, `ExternalPrincipalRetrievalIntegrationTests`, `PermissionsAdminIntegrationTests` | | Retrieval reloads active persisted department and Executive facts through Organization-owned queries and imports no Organization persistence or role types | `JpaKnowledgeAccessSubjectQueryTests`, `JpaOrganizationResourceQueryTests`, `ModulithVerificationTests#retrievalDoesNotDependOnOrganizationPersistenceOrRoleTypes`, `#retrievalOrganizationReadsUseOnlyOwnerQueries`, `SecureSourceVisibilityAdapterTests`, `KnowledgeRetrievalIntegrationTests` | | Citation opening consumes Source Ledger-owned immutable evidence without revision/blob persistence leakage, preserves typed unavailable audit reasons, and closes integrity-mismatched content before an allow audit | `SourceCitationEvidenceQueryTests`, `CitationContentServiceTests`, `ModulithVerificationTests#retrievalDoesNotDependOnSourceLedgerCitationPersistenceOrStatusTypes`, `#citationContentUsesTheSourceLedgerOwnerQuery` | -| Graph exploration, export, and curation consume only Retrieval's verifier and immutable snapshot; the verifier maps canonical scope failures and accepts only one exact current governing-evidence candidate | `CanonicalGraphEvidenceVerifierTests`, `KnowledgeGraphExplorerServiceTests`, `KnowledgeGraphExportServiceTests`, `KnowledgeGraphCurationServiceTests`, `ModulithVerificationTests#graphConsumesOnlyRetrievalGraphContracts` | +| Graph exploration, export, and curation consume only Retrieval's verifier and immutable snapshot; unknown Spaces fail closed, rechecks carry only the requested Space's assets, and the verifier accepts only one exact current governing-evidence candidate | `CanonicalGraphEvidenceVerifierTests`, `KnowledgeGraphExplorerServiceTests`, `KnowledgeGraphExportServiceTests`, `KnowledgeGraphCurationServiceTests`, `ModulithVerificationTests#graphConsumesOnlyRetrievalGraphContracts` | Request-boundary missing control role/incomplete actor returns `403`; generic resource `404` does not claim otherwise. Provider-backed evaluation, From 0a0f0eaaaf9a512762bccf10009ca66332f4e10d Mon Sep 17 00:00:00 2001 From: kl3inIT Date: Sun, 2 Aug 2026 10:39:16 +0700 Subject: [PATCH 4/4] docs(increment): record final main sync gates --- .../2026-07-31-spring-modulith-package-refactor/plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md index 43069070..b2c98f42 100644 --- a/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md +++ b/docs/increments/active/2026-07-31-spring-modulith-package-refactor/plan.md @@ -1279,3 +1279,8 @@ tests failed first against the permissive/default APIs; the corrected Graph, verifier, and full Modulith slice passed in 35s, followed by full Core in 1m43s. The terminating post-review `clean test` then passed all 99 tasks in 6m31s, including uncached API and Worker tests affected by the Core boundary change. + +After a second main sync at `8bf800c6` brought the governed document-action +Retrieval changes, the Graph/verifier classes plus full Modulith slice passed +again in 51s. Documentation passed for 506 Markdown files and 8 mirrored domain +pairs, and all 41 release-policy tests passed on Node 24.15.0.