diff --git a/docs/DOMAIN_MODEL.md b/docs/DOMAIN_MODEL.md index 5f4b88304..fd38e101e 100644 --- a/docs/DOMAIN_MODEL.md +++ b/docs/DOMAIN_MODEL.md @@ -62,6 +62,17 @@ which graph and search projections attach. - Parsed metadata and indexed text describe synchronized state; they are not independent knowledge sources. +### Resolution Contracts + +Project-scoped entity resolution treats the route project as the target. It may use a source path +to disambiguate notes inside that project, but it must never return an entity owned by another +project. + +Source-aware wikilink resolution treats the route project as the source/default context. A +qualified reference may select another target project, and the response names that target project +explicitly. Hosted callers must authorize the target project separately; authorization of the +source project does not grant access to the resolved target. + ### Observation An observation is a categorized semantic statement owned by one source entity. It contains diff --git a/src/basic_memory/api/v2/routers/knowledge_router.py b/src/basic_memory/api/v2/routers/knowledge_router.py index 05fcffd31..9b51cd49d 100644 --- a/src/basic_memory/api/v2/routers/knowledge_router.py +++ b/src/basic_memory/api/v2/routers/knowledge_router.py @@ -23,6 +23,7 @@ import logfire from basic_memory import db from basic_memory.services.exceptions import AmbiguousIdentifierError +from basic_memory.services.link_resolver import normalize_link_text from basic_memory.services.directory_deletes import DirectoryDeleteServiceError from basic_memory.services.note_content_writes import NoteContentMutationServiceError from basic_memory.ignore_utils import ( @@ -72,6 +73,8 @@ from basic_memory.schemas.v2 import ( EntityResolveRequest, EntityResolveResponse, + LinkResolveRequest, + LinkResolveResponse, EntityResponseV2, GraphEdge, GraphNode, @@ -84,7 +87,11 @@ ) from basic_memory.workspace_context import current_workspace_permalink_context from basic_memory.schemas.response import DirectoryMoveResult -from basic_memory.utils import validate_project_path +from basic_memory.utils import ( + generate_permalink, + normalize_project_reference, + validate_project_path, +) router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"]) @@ -278,10 +285,11 @@ async def resolve_identifier( session: SessionDep, read_cache: ResolveReadCacheDep, ) -> EntityResolveResponse: - """Resolve a string identifier (external_id, permalink, title, or path) to entity info. + """Resolve an entity inside the target project named in the route. This endpoint provides a bridge between v1-style identifiers and v2 external_ids. - Use this to convert existing references to the new UUID-based format. + Qualified identifiers cannot escape the route project. Use ``/links/resolve`` for + source-aware cross-project wikilinks. Args: data: Request containing the identifier to resolve @@ -338,20 +346,58 @@ async def resolve_identifier( resolution_method = "external_id" if entity else "search" if not entity: + # Trigger: the identifier uses legacy ``project/note`` syntax and the prefix + # names a different project. + # Why: non-strict resolution includes fuzzy search, which can otherwise turn a + # qualified miss into an unrelated entity from the route project. + # Outcome: allow an exact local title/path/permalink first, but reject the miss + # before fuzzy fallback and direct the caller to source-aware link resolution. + link_identifier, _ = normalize_link_text(data.identifier) + normalized_identifier = normalize_project_reference(link_identifier).strip("/") + project_prefix, separator, _ = normalized_identifier.partition("/") + referenced_project = None + if separator: + referenced_project = await project_repository.get_by_name( + session, project_prefix + ) + if referenced_project is None: + referenced_project = await project_repository.get_by_name_case_insensitive( + session, project_prefix + ) + if referenced_project is None: + referenced_project = await project_repository.get_by_permalink( + session, generate_permalink(project_prefix) + ) + qualified_other_project = ( + referenced_project is not None and referenced_project.id != project_id + ) + try: - entity = await link_resolver.resolve_link( + entity = await link_resolver.resolve_entity( data.identifier, source_path=data.source_path, - strict=data.strict, + strict=True if qualified_other_project else data.strict, session=session, ) except AmbiguousIdentifierError as exc: - # A strict resolve refused to guess between several same-title notes (#1148). - # Surface it as 409 so edit/move report ambiguity and ask for an exact id. - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(exc), - ) from exc + if qualified_other_project and not data.strict: + # An ambiguity proves that exact local title candidates exist, so retain + # the non-strict caller's historical shortest-path selection without + # opening the fuzzy qualified-miss path. + entity = await link_resolver.resolve_entity( + data.identifier, + source_path=data.source_path, + strict=False, + session=session, + ) + else: + # A strict resolve refused to guess between several same-title notes + # (#1148). Surface it as 409 so edit/move report ambiguity and ask for + # an exact id. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc if entity: if entity.permalink == data.identifier: resolution_method = "permalink" @@ -360,23 +406,27 @@ async def resolve_identifier( elif entity.file_path == data.identifier: resolution_method = "path" + if not entity and qualified_other_project: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Qualified project references must use /knowledge/links/resolve", + ) + if not entity: raise HTTPException( status_code=404, detail=f"Entity not found: '{data.identifier}'", ) - owner_project = await project_repository.get_by_id(session, entity.project_id) - if not owner_project: # pragma: no cover + if entity.project_id != project_id: # pragma: no cover raise HTTPException( - status_code=500, - detail="Resolved entity references an unknown project", + status_code=404, detail=f"Entity not found: '{data.identifier}'" ) result = EntityResolveResponse( external_id=entity.external_id, entity_id=entity.id, - project_external_id=owner_project.external_id, + project_external_id=project_external_id, permalink=entity.permalink, file_path=entity.file_path, title=entity.title, @@ -386,13 +436,85 @@ async def resolve_identifier( f"API v2 response: resolved '{data.identifier}' " f"to external_id={result.external_id} via {resolution_method}" ) - # Cross-project references depend on two projects. Keep phase-one - # generation invalidation exact by caching only local resolutions. - cached.cacheable = result.project_external_id == project_external_id cached.value = result return result +@router.post("/links/resolve", response_model=LinkResolveResponse) +async def resolve_link( + project_id: ProjectExternalIdPathDep, + source_project_external_id: Annotated[ + str, + Path( + alias="project_id", + description="Source/default project external UUID for wikilink resolution", + ), + ], + data: LinkResolveRequest, + link_resolver: LinkResolverV2ExternalDep, + project_repository: ProjectRepositoryDep, + session: SessionDep, +) -> LinkResolveResponse: + """Resolve a wikilink from the route project and return its explicit target project. + + The route project is the source/default context and owns ``source_path``. A qualified + reference may select another target project. Hosted callers must authorize the returned + ``target_project_external_id`` separately before exposing target metadata. + + This endpoint is intentionally not stored in the project read cache: a cross-project result + depends on both source and target generations, while entity resolution depends on one target. + """ + with logfire.span( + "api.request.knowledge.resolve_link", + entrypoint="api", + domain="knowledge", + action="resolve_link", + source_project_id=project_id, + source_project_external_id=source_project_external_id, + ): + logger.info(f"API v2 request: resolve_link for '{data.identifier}'") + + try: + entity = await link_resolver.resolve_link( + data.identifier, + source_path=data.source_path, + strict=data.strict, + session=session, + ) + except AmbiguousIdentifierError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + if not entity: + raise HTTPException( + status_code=404, detail=f"Link target not found: '{data.identifier}'" + ) + + target_project = await project_repository.get_by_id(session, entity.project_id) + if not target_project: # pragma: no cover + raise HTTPException( + status_code=500, + detail="Resolved link target references an unknown project", + ) + + resolution_method = "search" + if entity.permalink == data.identifier: + resolution_method = "permalink" + elif entity.title == data.identifier: + resolution_method = "title" + elif entity.file_path == data.identifier: + resolution_method = "path" + + return LinkResolveResponse( + external_id=entity.external_id, + entity_id=entity.id, + target_project_external_id=target_project.external_id, + permalink=entity.permalink, + file_path=entity.file_path, + title=entity.title, + resolution_method=resolution_method, + ) + + ## Single-file indexing endpoint diff --git a/src/basic_memory/schemas/v2/__init__.py b/src/basic_memory/schemas/v2/__init__.py index da11d0c7e..707c6c031 100644 --- a/src/basic_memory/schemas/v2/__init__.py +++ b/src/basic_memory/schemas/v2/__init__.py @@ -3,6 +3,8 @@ from basic_memory.schemas.v2.entity import ( EntityResolveRequest, EntityResolveResponse, + LinkResolveRequest, + LinkResolveResponse, EntityResponseV2, MoveEntityRequestV2, MoveDirectoryRequestV2, @@ -25,6 +27,8 @@ __all__ = [ "EntityResolveRequest", "EntityResolveResponse", + "LinkResolveRequest", + "LinkResolveResponse", "EntityResponseV2", "MoveEntityRequestV2", "MoveDirectoryRequestV2", diff --git a/src/basic_memory/schemas/v2/entity.py b/src/basic_memory/schemas/v2/entity.py index f98376bfc..1c7883cde 100644 --- a/src/basic_memory/schemas/v2/entity.py +++ b/src/basic_memory/schemas/v2/entity.py @@ -9,7 +9,7 @@ class EntityResolveRequest(BaseModel): - """Request to resolve a string identifier to an entity ID. + """Request to resolve a string identifier inside one target project. Supports resolution of: - Permalinks (e.g., "specs/search") @@ -17,7 +17,7 @@ class EntityResolveRequest(BaseModel): - File paths (e.g., "specs/search.md") When source_path is provided, resolution prefers notes closer to the source - (context-aware resolution for duplicate titles). + within the same project. Qualified references never change the target project. """ identifier: str = Field( @@ -38,9 +38,9 @@ class EntityResolveRequest(BaseModel): class EntityResolveResponse(BaseModel): - """Response from identifier resolution. + """Response from project-scoped entity resolution. - Returns the entity ID and associated metadata for the resolved entity. + The owning project always matches the target project in the request route. """ external_id: str = Field(..., description="External UUID (primary API identifier)") @@ -54,6 +54,47 @@ class EntityResolveResponse(BaseModel): ) +class LinkResolveRequest(BaseModel): + """Request to resolve a wikilink from one explicit source project.""" + + identifier: str = Field( + ..., + description="Wikilink or identifier to resolve from the source project", + min_length=1, + max_length=500, + ) + source_path: Optional[str] = Field( + None, + description="Path of the source note within the route project", + max_length=500, + ) + strict: bool = Field( + False, + description="If True, only exact matches are allowed (no fuzzy search fallback)", + ) + + +class LinkResolveResponse(BaseModel): + """Response from source-aware wikilink resolution. + + Hosted callers must separately authorize ``target_project_external_id`` before exposing + target metadata. Authorization of the source project in the request route is not sufficient. + """ + + external_id: str = Field(..., description="External UUID of the resolved entity") + entity_id: int = Field(..., description="Numeric entity ID (internal identifier)") + target_project_external_id: str = Field( + ..., + description="External UUID of the resolved target project; authorize it separately", + ) + permalink: Optional[str] = Field(None, description="Resolved entity permalink") + file_path: str = Field(..., description="Resolved entity path in the target project") + title: str = Field(..., description="Resolved entity title") + resolution_method: Literal["external_id", "permalink", "title", "path", "search"] = Field( + ..., description="How the wikilink was resolved" + ) + + class IndexFileRequest(BaseModel): """Request to index a single markdown file that exists on disk. diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index cd59b0b6a..17479e42a 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -71,6 +71,20 @@ async def detect_project_from_workspace_identifier_prefix( return workspace_resolution.project_identifier +def normalize_link_text(link_text: str) -> tuple[str, str | None]: + """Strip wikilink syntax and return the target text plus optional alias.""" + text = link_text.strip() + if text.startswith("[[") and text.endswith("]]"): + text = text[2:-2] + + alias = None + if "|" in text: + text, alias = text.split("|", 1) + alias = alias.strip() + + return text.strip(), alias + + class LinkResolver: """Service for resolving markdown links to permalinks. @@ -105,6 +119,48 @@ def __init__( self._entity_repository_cache: Dict[int, EntityRepository] = {} self._search_service_cache: Dict[int, SearchService] = {} + async def resolve_entity( + self, + identifier: str, + *, + strict: bool = False, + source_path: Optional[str] = None, + load_relations: bool = True, + session: AsyncSession | None = None, + ) -> Optional[Entity]: + """Resolve an entity without leaving the resolver's project scope. + + Unlike :meth:`resolve_link`, project-qualified identifiers do not select a different + repository. This is the target-project contract used by entity read and mutation flows. + """ + clean_text, _ = self._normalize_link_text(identifier) + + async with db.scoped_session(self.session_maker, session) as active_session: + try: + canonical_id = str(uuid_mod.UUID(clean_text)) + entity = await self.entity_repository.get_by_external_id( + active_session, + canonical_id, + load_relations=load_relations, + ) + if entity: + return entity + except ValueError: + pass + + project_permalink = await self._get_current_project_permalink(active_session) + return await self._resolve_in_project( + session=active_session, + entity_repository=self.entity_repository, + search_service=self.search_service, + link_text=clean_text, + use_search=True, + strict=strict, + source_path=source_path, + project_permalink=project_permalink, + load_relations=load_relations, + ) + async def resolve_link( self, link_text: str, @@ -227,24 +283,7 @@ def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]: Returns: Tuple of (normalized_text, alias or None) """ - # Strip whitespace - text = link_text.strip() - - # Remove enclosing brackets if present - if text.startswith("[[") and text.endswith("]]"): - text = text[2:-2] - - # Handle wiki link aliases (format: [[actual|alias]]) - alias = None - if "|" in text: - text, alias = text.split("|", 1) - text = text.strip() - alias = alias.strip() - else: - # Strip whitespace from text even if no alias - text = text.strip() - - return text, alias + return normalize_link_text(link_text) async def _resolve_in_project( self, diff --git a/tests/api/v2/test_knowledge_resolution_contract.py b/tests/api/v2/test_knowledge_resolution_contract.py new file mode 100644 index 000000000..6e1b6299d --- /dev/null +++ b/tests/api/v2/test_knowledge_resolution_contract.py @@ -0,0 +1,105 @@ +"""Focused API regressions for strict entity versus cross-project link resolution.""" + +from datetime import UTC, datetime + +import pytest +from httpx import AsyncClient + +from basic_memory import db +from basic_memory.models import Entity, Project +from basic_memory.repository.entity_repository import EntityRepository +from basic_memory.repository.project_repository import ProjectRepository +from basic_memory.schemas.v2 import EntityResolveResponse + + +@pytest.mark.asyncio +async def test_strict_entity_resolution_rejects_legacy_cross_project_path_after_local_miss( + client: AsyncClient, + session_maker, + test_project: Project, + tmp_path, + v2_project_url: str, +) -> None: + """A project/path reference stays local when present and migrates when absent.""" + now = datetime.now(UTC) + async with db.scoped_session(session_maker) as session: + await ProjectRepository().create( + session, + { + "name": "other-project", + "description": "Secondary project", + "path": str(tmp_path / "other-project"), + "is_active": True, + "is_default": False, + }, + ) + local_entity = await EntityRepository(project_id=test_project.id).add( + session, + Entity( + title="Local note", + note_type="note", + content_type="text/markdown", + file_path="other-project/docs/local-note.md", + permalink="other-project/docs/local-note", + created_at=now, + updated_at=now, + project_id=test_project.id, + ), + ) + namespaced_title_entity = await EntityRepository(project_id=test_project.id).add( + session, + Entity( + title="C++::ABI", + note_type="note", + content_type="text/markdown", + file_path="docs/cxx-abi.md", + permalink="docs/cxx-abi", + created_at=now, + updated_at=now, + project_id=test_project.id, + ), + ) + + local_response = await client.post( + f"{v2_project_url}/knowledge/resolve", + json={"identifier": "other-project/docs/local-note", "strict": True}, + ) + fuzzy_entity_response = await client.post( + f"{v2_project_url}/knowledge/entities", + json={ + "title": "Missing", + "directory": "local", + "content": "A local note that must not satisfy a qualified project reference.", + }, + ) + assert fuzzy_entity_response.status_code == 202 + qualified_miss_response = await client.post( + f"{v2_project_url}/knowledge/resolve", + json={"identifier": "other-project/docs/missing", "strict": True}, + ) + non_strict_qualified_miss_response = await client.post( + f"{v2_project_url}/knowledge/resolve", + json={"identifier": "other-project/docs/missing"}, + ) + aliased_qualified_miss_response = await client.post( + f"{v2_project_url}/knowledge/resolve", + json={"identifier": " [[other-project/docs/missing|Missing label]] "}, + ) + namespaced_title_response = await client.post( + f"{v2_project_url}/knowledge/resolve", + json={"identifier": "C++::ABI", "strict": True}, + ) + + assert local_response.status_code == 200 + assert EntityResolveResponse.model_validate(local_response.json()).entity_id == local_entity.id + assert namespaced_title_response.status_code == 200 + assert ( + EntityResolveResponse.model_validate(namespaced_title_response.json()).entity_id + == namespaced_title_entity.id + ) + assert qualified_miss_response.status_code == 400 + assert "/knowledge/links/resolve" in qualified_miss_response.json()["detail"] + assert non_strict_qualified_miss_response.status_code == 400 + assert "/knowledge/links/resolve" in non_strict_qualified_miss_response.json()["detail"] + assert aliased_qualified_miss_response.status_code == 400 + assert "/knowledge/links/resolve" in aliased_qualified_miss_response.json()["detail"] diff --git a/tests/api/v2/test_knowledge_router.py b/tests/api/v2/test_knowledge_router.py index 6fbb9e843..f76e20b14 100644 --- a/tests/api/v2/test_knowledge_router.py +++ b/tests/api/v2/test_knowledge_router.py @@ -24,7 +24,7 @@ from basic_memory.runtime.note_content import NOTE_CONTENT_BASE_CHECKSUM_HEADER from basic_memory.schemas import DeleteEntitiesResponse from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult -from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse +from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse, LinkResolveResponse from basic_memory.services.search_service import SearchService @@ -79,13 +79,13 @@ async def test_resolve_identifier_by_permalink( @pytest.mark.asyncio -async def test_resolve_identifier_returns_target_project_external_id_for_cross_project_link( +async def test_entity_and_link_resolution_use_distinct_project_contracts( client: AsyncClient, session_maker, tmp_path, v2_project_url, ): - """Cross-project resolves should expose the owning project external ID.""" + """Entity lookup stays local while link lookup exposes its cross-project target.""" project_repository = ProjectRepository() now = datetime.now(timezone.utc) async with db.scoped_session(session_maker) as session: @@ -114,15 +114,21 @@ async def test_resolve_identifier_returns_target_project_external_id_for_cross_p ), ) - response = await client.post( + entity_response = await client.post( f"{v2_project_url}/knowledge/resolve", json={"identifier": "other-project::Cross Project Note", "strict": True}, ) + link_response = await client.post( + f"{v2_project_url}/knowledge/links/resolve", + json={"identifier": "other-project::Cross Project Note", "strict": True}, + ) - assert response.status_code == 200 - resolved = EntityResolveResponse.model_validate(response.json()) + assert entity_response.status_code == 400 + assert "/knowledge/links/resolve" in entity_response.json()["detail"] + assert link_response.status_code == 200 + resolved = LinkResolveResponse.model_validate(link_response.json()) assert resolved.entity_id == target.id - assert resolved.project_external_id == other_project.external_id + assert resolved.target_project_external_id == other_project.external_id @pytest.mark.asyncio @@ -187,9 +193,7 @@ async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project @pytest.mark.asyncio -async def test_resolve_identifier_with_source_path_no_fuzzy_match( - client: AsyncClient, v2_project_url -): +async def test_resolve_link_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url): """Test that context-aware resolution also uses strict mode. Even with source_path for context-aware resolution, nonexistent @@ -209,12 +213,13 @@ async def test_resolve_identifier_with_source_path_no_fuzzy_match( resolve_data = { "identifier": "nonexistent", "source_path": "testing/nested/other-note.md", + "strict": True, } - response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data) + response = await client.post(f"{v2_project_url}/knowledge/links/resolve", json=resolve_data) # Must return 404, not a fuzzy match assert response.status_code == 404 - assert "Entity not found" in response.json()["detail"] + assert "Link target not found" in response.json()["detail"] @pytest.mark.asyncio