feat(api): expose owning project on entity resolve

Expose project_external_id on v2 entity resolve responses so cloud callers can authorize cross-project targets without a second tenant DB lookup.
This commit is contained in:
Paul Hernandez
2026-06-02 18:01:17 -05:00
committed by GitHub
parent 61891f2e33
commit fc2ee07076
3 changed files with 61 additions and 1 deletions
@@ -18,6 +18,7 @@ from basic_memory.deps import (
EntityServiceV2ExternalDep,
SearchServiceV2ExternalDep,
LinkResolverV2ExternalDep,
ProjectRepositoryDep,
ProjectConfigV2ExternalDep,
AppConfigDep,
EntityRepositoryV2ExternalDep,
@@ -152,6 +153,7 @@ async def resolve_identifier(
data: EntityResolveRequest,
link_resolver: LinkResolverV2ExternalDep,
entity_repository: EntityRepositoryV2ExternalDep,
project_repository: ProjectRepositoryDep,
) -> EntityResolveResponse:
"""Resolve a string identifier (external_id, permalink, title, or path) to entity info.
@@ -175,6 +177,7 @@ async def resolve_identifier(
{
"external_id": "550e8400-e29b-41d4-a716-446655440000",
"entity_id": 123,
"project_external_id": "4b9b7a10-7a63-48d2-ae3f-0d6a2c69313f",
"permalink": "specs/search",
"file_path": "specs/search.md",
"title": "Search Specification",
@@ -209,9 +212,17 @@ async def resolve_identifier(
if not entity:
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
owner_project = await project_repository.get_by_id(entity.project_id)
if not owner_project: # pragma: no cover
raise HTTPException(
status_code=500,
detail="Resolved entity references an unknown project",
)
result = EntityResolveResponse(
external_id=entity.external_id,
entity_id=entity.id,
project_external_id=owner_project.external_id,
permalink=entity.permalink,
file_path=entity.file_path,
title=entity.title,
+1
View File
@@ -45,6 +45,7 @@ class EntityResolveResponse(BaseModel):
external_id: str = Field(..., description="External UUID (primary API identifier)")
entity_id: int = Field(..., description="Numeric entity ID (internal identifier)")
project_external_id: str = Field(..., description="External UUID of the owning project")
permalink: Optional[str] = Field(None, description="Entity permalink")
file_path: str = Field(..., description="Relative file path")
title: str = Field(..., description="Entity title")
+49 -1
View File
@@ -1,11 +1,14 @@
"""Tests for V2 knowledge graph API routes (ID-based endpoints)."""
from datetime import datetime, timezone
import uuid
import pytest
from httpx import AsyncClient
from basic_memory.models import Project
from basic_memory.models import Entity as EntityModel, Project
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.schemas import DeleteEntitiesResponse
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
@@ -40,10 +43,55 @@ async def test_resolve_identifier_by_permalink(
assert response.status_code == 200
resolved = EntityResolveResponse.model_validate(response.json())
assert resolved.entity_id == entity_id
assert resolved.project_external_id == test_project.external_id
assert resolved.permalink == created_entity.permalink
assert resolved.resolution_method == "permalink"
@pytest.mark.asyncio
async def test_resolve_identifier_returns_target_project_external_id_for_cross_project_link(
client: AsyncClient,
session_maker,
tmp_path,
v2_project_url,
):
"""Cross-project resolves should expose the owning project external ID."""
project_repository = ProjectRepository(session_maker)
other_project = await project_repository.create(
{
"name": "other-project",
"description": "Secondary project",
"path": str(tmp_path / "other-project"),
"is_active": True,
"is_default": False,
}
)
now = datetime.now(timezone.utc)
other_entity_repository = EntityRepository(session_maker, project_id=other_project.id)
target = await other_entity_repository.add(
EntityModel(
title="Cross Project Note",
note_type="note",
content_type="text/markdown",
file_path="docs/Cross Project Note.md",
permalink=f"{other_project.permalink}/docs/cross-project-note",
created_at=now,
updated_at=now,
project_id=other_project.id,
)
)
response = await client.post(
f"{v2_project_url}/knowledge/resolve",
json={"identifier": "other-project::Cross Project Note", "strict": True},
)
assert response.status_code == 200
resolved = EntityResolveResponse.model_validate(response.json())
assert resolved.entity_id == target.id
assert resolved.project_external_id == other_project.external_id
@pytest.mark.asyncio
async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url):
"""Test resolving a non-existent identifier returns 404."""