Implemented profile-scoped CMIS Browser Binding routes

This commit is contained in:
2026-05-07 01:12:38 +02:00
parent 41da61896b
commit 7e168e93d3
6 changed files with 650 additions and 1 deletions

View File

@@ -65,3 +65,26 @@ The mapper returns `None` for assets or relationships that the access-point
profile must not expose. It does not fetch from repositories directly; callers profile must not expose. It does not fetch from repositories directly; callers
provide the asset, representations, versions, metadata records, and provide the asset, representations, versions, metadata records, and
relationships they have already authorized or loaded. relationships they have already authorized or loaded.
## Browser Binding MVP Slice
The service exposes profile-scoped Browser Binding MVP routes:
- `GET /cmis`
- `GET /cmis/{access_point_id}/browser`
- `GET /cmis/{access_point_id}/browser/types`
- `GET /cmis/{access_point_id}/browser/children`
- `GET /cmis/{access_point_id}/browser/object/{object_id}`
- `GET /cmis/{access_point_id}/browser/content/{object_id}`
- `GET /cmis/{access_point_id}/browser/query`
- `GET /cmis/{access_point_id}/browser/relationships`
- `GET /cmis/{access_point_id}/browser/changes`
The MVP supports repository info, type definitions, synthetic root children,
object reads, content stream descriptors, a constrained document query subset,
relationship objects, and audit-backed change entries. Unsupported query
grammar returns structured diagnostics.
Route-level tests are present but skip when the optional FastAPI/httpx service
dependencies are not installed. Runtime-level Browser Binding tests cover the
same behavior in the default Python test suite.

View File

@@ -19,6 +19,10 @@ from kontextual_engine.core import (
AuditEvent, AuditEvent,
AuditOutcome, AuditOutcome,
Classification, Classification,
CMISAccessPoint,
CMISAccessProfile,
CMISAction,
CMISDomainMapper,
ContextEntity, ContextEntity,
ContextEntityType, ContextEntityType,
IngestionIdentityPolicy, IngestionIdentityPolicy,
@@ -316,6 +320,203 @@ class ServiceRuntime:
"openapi_version": OPENAPI_VERSION, "openapi_version": OPENAPI_VERSION,
} }
def cmis_access_points(self) -> dict[str, Any]:
access_points = [_cmis_access_point(profile) for profile in _cmis_profiles()]
return {"items": [access_point.to_dict() for access_point in access_points], "count": len(access_points)}
def cmis_repository_info(self, access_point_id: str) -> dict[str, Any]:
return self._cmis_mapper(access_point_id).repository_info()
def cmis_type_definitions(self, access_point_id: str) -> dict[str, Any]:
definitions = self._cmis_mapper(access_point_id).type_definitions()
return {"items": definitions, "count": len(definitions)}
def cmis_children(
self,
access_point_id: str,
context: OperationContext,
*,
folder_id: str | None = None,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_CHILDREN, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getChildren")
projections = [
projection.to_dict()
for asset in self.repository.list_assets()
if (
projection := mapper.map_asset(
asset,
context,
representations=self.repository.list_representations(asset_id=asset.id),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
for relationship in self.repository.list_relationships(source_id=asset.id)
],
metadata_records=self.repository.list_metadata_records(asset.id),
)
)
]
paged = projections[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
return {
"folder_id": folder_id or mapper.access_point.root_folder_id,
"objects": paged,
"num_items": len(paged),
"has_more_items": len(projections) > max(skip_count, 0) + len(paged),
"total_num_items": len(projections),
}
def cmis_object(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getObject")
asset_id = _cmis_asset_id(object_id)
asset = self.repository.get_asset(asset_id)
projection = mapper.map_asset(
asset,
context,
representations=self.repository.list_representations(asset_id=asset.id),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
for relationship in self.repository.list_relationships(source_id=asset.id)
],
metadata_records=self.repository.list_metadata_records(asset.id),
)
if projection is None:
raise NotFoundError(
"CMIS object not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
return projection.to_dict()
def cmis_content_stream(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_CONTENT_STREAM, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getContentStream")
object_projection = self.cmis_object(access_point_id, object_id, context)
content_stream = object_projection.get("content_stream")
if not content_stream:
raise NotFoundError(
"CMIS content stream not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
return content_stream
def cmis_query(
self,
access_point_id: str,
query: str,
context: OperationContext,
*,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.QUERY, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "query")
normalized = query.strip().lower()
if normalized not in {"select * from cmis:document", "select * from kontextual:document"}:
raise ValidationError(
"Unsupported CMIS query subset",
details={
"query": query,
"supported": ["SELECT * FROM cmis:document", "SELECT * FROM kontextual:document"],
},
)
children = self.cmis_children(
access_point_id,
context,
skip_count=skip_count,
max_items=max_items,
)
return {
"query": query,
"results": children["objects"],
"num_items": children["num_items"],
"has_more_items": children["has_more_items"],
"total_num_items": children["total_num_items"],
}
def cmis_relationships(
self,
access_point_id: str,
context: OperationContext,
*,
object_id: str | None = None,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_RELATIONSHIPS, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getRelationships")
source_id = _cmis_asset_id(object_id) if object_id else None
projections = [
projection.to_dict()
for relationship in self.repository.list_relationships(source_id=source_id)
if (projection := mapper.map_relationship(relationship, context))
]
return {"items": projections, "count": len(projections)}
def cmis_change_log(
self,
access_point_id: str,
context: OperationContext,
*,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_CHANGE_LOG, context)
if not decision.allowed:
raise _cmis_authorization_error(decision, "getContentChanges")
events = self.repository.list_audit_events()
changes = [
{
"change_id": event.event_id,
"change_type": _cmis_change_type(event.operation),
"object_id": event.target.replace("asset:", "cmis:asset:", 1),
"change_time": event.occurred_at,
"actor_id": event.actor_id,
"correlation_id": event.correlation_id,
}
for event in events
if event.target.startswith("asset:")
]
paged = changes[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
return {
"change_log_token": changes[-1]["change_id"] if changes else None,
"changes": paged,
"num_items": len(paged),
"has_more_items": len(changes) > max(skip_count, 0) + len(paged),
"total_num_items": len(changes),
}
def _cmis_mapper(self, access_point_id: str) -> CMISDomainMapper:
for profile in _cmis_profiles():
if profile.name == access_point_id:
return CMISDomainMapper(_cmis_access_point(profile))
raise NotFoundError(
"CMIS access point not found",
details={"access_point_id": access_point_id, "available": [profile.name for profile in _cmis_profiles()]},
)
def create_asset(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]: def create_asset(self, payload: dict[str, Any], context: OperationContext) -> dict[str, Any]:
classification = Classification.from_dict(payload["classification"]) classification = Classification.from_dict(payload["classification"])
result = self.asset_service().create_asset( result = self.asset_service().create_asset(
@@ -1718,6 +1919,91 @@ def create_app(runtime: ServiceRuntime | None = None):
) -> dict[str, Any]: ) -> dict[str, Any]:
return context.to_dict() return context.to_dict()
@app.get("/cmis", tags=["cmis"])
def cmis_access_points() -> dict[str, Any]:
return response(runtime.cmis_access_points)
@app.get("/cmis/{access_point_id}/browser", tags=["cmis"])
def cmis_repository_info(access_point_id: str) -> dict[str, Any]:
return response(runtime.cmis_repository_info, access_point_id)
@app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"])
def cmis_types(access_point_id: str) -> dict[str, Any]:
return response(runtime.cmis_type_definitions, access_point_id)
@app.get("/cmis/{access_point_id}/browser/children", tags=["cmis"])
def cmis_children(
access_point_id: str,
folder_id: str | None = Query(None),
skip_count: int = Query(0),
max_items: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(
runtime.cmis_children,
access_point_id,
context,
folder_id=folder_id,
skip_count=skip_count,
max_items=max_items,
)
@app.get("/cmis/{access_point_id}/browser/object/{object_id:path}", tags=["cmis"])
def cmis_object(
access_point_id: str,
object_id: str,
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_object, access_point_id, object_id, context)
@app.get("/cmis/{access_point_id}/browser/content/{object_id:path}", tags=["cmis"])
def cmis_content_stream(
access_point_id: str,
object_id: str,
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_content_stream, access_point_id, object_id, context)
@app.get("/cmis/{access_point_id}/browser/query", tags=["cmis"])
def cmis_query(
access_point_id: str,
q: str = Query("SELECT * FROM cmis:document"),
skip_count: int = Query(0),
max_items: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(
runtime.cmis_query,
access_point_id,
q,
context,
skip_count=skip_count,
max_items=max_items,
)
@app.get("/cmis/{access_point_id}/browser/relationships", tags=["cmis"])
def cmis_relationships(
access_point_id: str,
object_id: str | None = Query(None),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(runtime.cmis_relationships, access_point_id, context, object_id=object_id)
@app.get("/cmis/{access_point_id}/browser/changes", tags=["cmis"])
def cmis_changes(
access_point_id: str,
skip_count: int = Query(0),
max_items: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]:
return response(
runtime.cmis_change_log,
access_point_id,
context,
skip_count=skip_count,
max_items=max_items,
)
@app.post(f"{prefix}/assets", tags=["assets"]) @app.post(f"{prefix}/assets", tags=["assets"])
def create_asset( def create_asset(
payload: dict[str, Any], payload: dict[str, Any],
@@ -2105,6 +2391,57 @@ def create_app(runtime: ServiceRuntime | None = None):
return app return app
def _cmis_profiles() -> tuple[CMISAccessProfile, ...]:
return (
CMISAccessProfile.readonly_browser(),
CMISAccessProfile.governed_authoring(),
CMISAccessProfile.admin_export(),
CMISAccessProfile.compat_tck(),
)
def _cmis_access_point(profile: CMISAccessProfile) -> CMISAccessPoint:
return CMISAccessPoint(
access_point_id=profile.name,
repository_id=f"kontextual-{profile.name}",
profile=profile,
base_path=f"/cmis/{profile.name}/browser",
metadata={"repository_name": f"Kontextual Engine {profile.name}"},
)
def _cmis_asset_id(object_id: str | None) -> str:
if not object_id:
raise ValidationError("CMIS object id is required", details={"field": "object_id"})
normalized = object_id.strip("/")
if normalized.startswith("cmis:asset:"):
return normalized.removeprefix("cmis:asset:")
if normalized.startswith("asset:"):
return normalized.removeprefix("asset:")
return normalized
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
return AuthorizationError(
"CMIS operation denied by access-point profile",
details={
"operation": operation,
"policy_decision": decision.to_dict(),
"code": "cmis.permission_denied",
},
)
def _cmis_change_type(operation: str) -> str:
if operation.endswith(".create") or operation == "asset.create":
return "created"
if "delete" in operation:
return "deleted"
if "metadata" in operation or "content" in operation or "lifecycle" in operation:
return "updated"
return "security"
def _age_seconds(start: str, end: str) -> float: def _age_seconds(start: str, end: str) -> float:
try: try:
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import pytest
from kontextual_engine import (
AssetRepresentation,
Classification,
RepresentationKind,
ServiceRuntime,
Sensitivity,
create_app,
)
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository
pytestmark = pytest.mark.cmis
@pytest.fixture
def cmis_client():
pytest.importorskip("fastapi")
pytest.importorskip("httpx")
from fastapi.testclient import TestClient
runtime = ServiceRuntime(repository=InMemoryAssetRegistryRepository())
context = runtime.operation_context(actor_id="cmis-test", correlation_id="corr-cmis-api")
runtime.asset_service().create_asset(
"Source",
Classification(
asset_type="document",
sensitivity=Sensitivity.INTERNAL,
owner="Platform Knowledge",
topics=("cmis",),
),
context,
asset_id="asset-source",
representations=[
AssetRepresentation.from_content(
"asset-source",
RepresentationKind.SOURCE,
"text/markdown",
"# Source\n\nCMIS Browser Binding test fixture.",
storage_ref="memory://asset-source/source",
)
],
)
runtime.create_asset(
{
"asset_id": "asset-public",
"title": "Public Target",
"classification": {"asset_type": "document", "sensitivity": "public"},
},
context,
)
runtime.create_asset(
{
"asset_id": "asset-confidential",
"title": "Confidential Target",
"classification": {"asset_type": "document", "sensitivity": "confidential"},
},
context,
)
runtime.create_relationship(
{
"source_asset_id": "asset-source",
"target_id": "asset-public",
"predicate": "references",
"target_kind": "asset",
"confidence": 1.0,
},
context,
)
with TestClient(create_app(runtime)) as test_client:
yield test_client
def test_cmis_browser_binding_routes_are_advertised_in_openapi(cmis_client) -> None:
paths = cmis_client.get("/openapi.json").json()["paths"]
assert "/cmis" in paths
assert "/cmis/{access_point_id}/browser" in paths
assert "/cmis/{access_point_id}/browser/types" in paths
assert "/cmis/{access_point_id}/browser/children" in paths
assert "/cmis/{access_point_id}/browser/object/{object_id}" in paths
assert "/cmis/{access_point_id}/browser/content/{object_id}" in paths
assert "/cmis/{access_point_id}/browser/query" in paths
assert "/cmis/{access_point_id}/browser/relationships" in paths
assert "/cmis/{access_point_id}/browser/changes" in paths
def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
access_points = cmis_client.get("/cmis").json()
repository = cmis_client.get("/cmis/readonly-browser/browser").json()
types = cmis_client.get("/cmis/readonly-browser/browser/types").json()
assert access_points["count"] == 4
assert repository["repository_id"] == "kontextual-readonly-browser"
assert repository["cmis_version_supported"] == "1.1"
assert repository["capabilities"]["capability_query"] == "metadataonly"
assert {item["base_type_id"] for item in types["items"]} >= {
"cmis:document",
"cmis:folder",
"cmis:relationship",
}
def test_cmis_readonly_children_object_content_query_relationships_and_changes(cmis_client) -> None:
children = cmis_client.get("/cmis/readonly-browser/browser/children").json()
object_response = cmis_client.get(
"/cmis/readonly-browser/browser/object/cmis:asset:asset-source"
).json()
content = cmis_client.get(
"/cmis/readonly-browser/browser/content/cmis:asset:asset-source"
).json()
query = cmis_client.get(
"/cmis/readonly-browser/browser/query",
params={"q": "SELECT * FROM cmis:document"},
).json()
relationships = cmis_client.get(
"/cmis/readonly-browser/browser/relationships",
params={"object_id": "cmis:asset:asset-source"},
).json()
changes = cmis_client.get("/cmis/readonly-browser/browser/changes").json()
child_ids = {item["object_id"] for item in children["objects"]}
assert "cmis:asset:asset-source" in child_ids
assert "cmis:asset:asset-public" in child_ids
assert "cmis:asset:asset-confidential" not in child_ids
assert object_response["properties"]["kontextual:assetId"] == "asset-source"
assert "get_content_stream" in object_response["allowable_actions"]
assert content["mime_type"] == "text/markdown"
assert query["total_num_items"] == children["total_num_items"]
assert relationships["count"] == 1
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-public"
assert changes["total_num_items"] >= 3
def test_cmis_profile_gates_visibility_by_access_point(cmis_client) -> None:
readonly = cmis_client.get("/cmis/readonly-browser/browser/children").json()
admin_denied = cmis_client.get("/cmis/admin-export/browser/children")
admin_allowed = cmis_client.get(
"/cmis/admin-export/browser/children",
headers={"X-Actor-Type": "service_account", "X-Actor-Id": "svc-export"},
).json()
readonly_ids = {item["object_id"] for item in readonly["objects"]}
admin_ids = {item["object_id"] for item in admin_allowed["objects"]}
assert admin_denied.status_code == 403
assert "cmis:asset:asset-confidential" not in readonly_ids
assert "cmis:asset:asset-confidential" in admin_ids
def test_cmis_query_reports_unsupported_subset_diagnostics(cmis_client) -> None:
response = cmis_client.get(
"/cmis/readonly-browser/browser/query",
params={"q": "SELECT * FROM cmis:document JOIN cmis:relationship"},
)
assert response.status_code == 422
assert response.json()["detail"]["details"]["supported"] == [
"SELECT * FROM cmis:document",
"SELECT * FROM kontextual:document",
]

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import pytest
from kontextual_engine import (
AssetRepresentation,
Classification,
RepresentationKind,
ServiceRuntime,
Sensitivity,
)
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository
pytestmark = pytest.mark.cmis
@pytest.fixture
def cmis_runtime() -> tuple[ServiceRuntime, object]:
runtime = ServiceRuntime(repository=InMemoryAssetRegistryRepository())
context = runtime.operation_context(actor_id="cmis-runtime", correlation_id="corr-cmis-runtime")
runtime.asset_service().create_asset(
"Runtime Source",
Classification(
asset_type="document",
sensitivity=Sensitivity.INTERNAL,
owner="Platform Knowledge",
topics=("cmis",),
),
context,
asset_id="asset-runtime-source",
representations=[
AssetRepresentation.from_content(
"asset-runtime-source",
RepresentationKind.SOURCE,
"text/markdown",
"# Runtime Source\n\nCMIS runtime fixture.",
storage_ref="memory://asset-runtime-source/source",
)
],
)
runtime.create_asset(
{
"asset_id": "asset-runtime-public",
"title": "Runtime Public",
"classification": {"asset_type": "document", "sensitivity": "public"},
},
context,
)
runtime.create_asset(
{
"asset_id": "asset-runtime-confidential",
"title": "Runtime Confidential",
"classification": {"asset_type": "document", "sensitivity": "confidential"},
},
context,
)
runtime.create_relationship(
{
"source_asset_id": "asset-runtime-source",
"target_id": "asset-runtime-public",
"predicate": "references",
"target_kind": "asset",
"confidence": 0.99,
},
context,
)
return runtime, context
def test_runtime_cmis_browser_repository_types_children_and_object(cmis_runtime) -> None:
runtime, context = cmis_runtime
access_points = runtime.cmis_access_points()
repository = runtime.cmis_repository_info("readonly-browser")
types = runtime.cmis_type_definitions("readonly-browser")
children = runtime.cmis_children("readonly-browser", context)
obj = runtime.cmis_object("readonly-browser", "cmis:asset:asset-runtime-source", context)
assert access_points["count"] == 4
assert repository["repository_id"] == "kontextual-readonly-browser"
assert repository["capabilities"]["capability_get_descendants"] is True
assert {item["base_type_id"] for item in types["items"]} >= {"cmis:document", "cmis:folder"}
object_ids = {item["object_id"] for item in children["objects"]}
assert "cmis:asset:asset-runtime-source" in object_ids
assert "cmis:asset:asset-runtime-public" in object_ids
assert "cmis:asset:asset-runtime-confidential" not in object_ids
assert obj["properties"]["kontextual:assetId"] == "asset-runtime-source"
def test_runtime_cmis_browser_content_query_relationships_and_changes(cmis_runtime) -> None:
runtime, context = cmis_runtime
content = runtime.cmis_content_stream("readonly-browser", "cmis:asset:asset-runtime-source", context)
query = runtime.cmis_query("readonly-browser", "SELECT * FROM cmis:document", context)
relationships = runtime.cmis_relationships(
"readonly-browser",
context,
object_id="cmis:asset:asset-runtime-source",
)
changes = runtime.cmis_change_log("readonly-browser", context)
assert content["mime_type"] in {"text/plain", "text/markdown"}
assert query["total_num_items"] == 2
assert relationships["count"] == 1
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-runtime-public"
assert changes["total_num_items"] >= 3
def test_runtime_cmis_browser_rejects_unsupported_query_subset(cmis_runtime) -> None:
runtime, context = cmis_runtime
with pytest.raises(Exception) as exc_info:
runtime.cmis_query(
"readonly-browser",
"SELECT * FROM cmis:document JOIN cmis:relationship",
context,
)
assert "Unsupported CMIS query subset" in str(exc_info.value)

View File

@@ -654,6 +654,9 @@ def test_service_health_readiness_version_and_openapi_contracts(client) -> None:
assert "/api/v1/ready" in paths assert "/api/v1/ready" in paths
assert "/api/v1/version" in paths assert "/api/v1/version" in paths
assert "/api/v1/context" in paths assert "/api/v1/context" in paths
assert "/cmis" in paths
assert "/cmis/{access_point_id}/browser" in paths
assert "/cmis/{access_point_id}/browser/children" in paths
assert "/api/v1/assets" in paths assert "/api/v1/assets" in paths
assert "/api/v1/relationships" in paths assert "/api/v1/relationships" in paths
assert "/api/v1/audit/events" in paths assert "/api/v1/audit/events" in paths

View File

@@ -44,6 +44,8 @@ suite.
- `src/kontextual_engine/core/cmis.py` - `src/kontextual_engine/core/cmis.py`
- `tests/cmis/test_cmis_access_profiles.py` - `tests/cmis/test_cmis_access_profiles.py`
- `tests/cmis/test_cmis_domain_mapper.py` - `tests/cmis/test_cmis_domain_mapper.py`
- `tests/cmis/test_cmis_runtime_browser_binding.py`
- `tests/cmis/test_cmis_browser_binding_api.py`
## Architecture Constraint ## Architecture Constraint
@@ -91,7 +93,7 @@ Acceptance:
```task ```task
id: KONT-WP-0012-T003 id: KONT-WP-0012-T003
status: todo status: done
priority: high priority: high
state_hub_task_id: "b9f5d790-f291-4613-89da-5d47e7887a9e" state_hub_task_id: "b9f5d790-f291-4613-89da-5d47e7887a9e"
``` ```