diff --git a/docs/cmis-profiled-access-points-implementation.md b/docs/cmis-profiled-access-points-implementation.md index 88032ca..43b77e7 100644 --- a/docs/cmis-profiled-access-points-implementation.md +++ b/docs/cmis-profiled-access-points-implementation.md @@ -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 provide the asset, representations, versions, metadata records, and 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. diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py index 781ef75..39f83fd 100644 --- a/src/kontextual_engine/api/app.py +++ b/src/kontextual_engine/api/app.py @@ -19,6 +19,10 @@ from kontextual_engine.core import ( AuditEvent, AuditOutcome, Classification, + CMISAccessPoint, + CMISAccessProfile, + CMISAction, + CMISDomainMapper, ContextEntity, ContextEntityType, IngestionIdentityPolicy, @@ -316,6 +320,203 @@ class ServiceRuntime: "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]: classification = Classification.from_dict(payload["classification"]) result = self.asset_service().create_asset( @@ -1718,6 +1919,91 @@ def create_app(runtime: ServiceRuntime | None = None): ) -> dict[str, Any]: 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"]) def create_asset( payload: dict[str, Any], @@ -2105,6 +2391,57 @@ def create_app(runtime: ServiceRuntime | None = None): 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: try: start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py new file mode 100644 index 0000000..df11eca --- /dev/null +++ b/tests/cmis/test_cmis_browser_binding_api.py @@ -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", + ] diff --git a/tests/cmis/test_cmis_runtime_browser_binding.py b/tests/cmis/test_cmis_runtime_browser_binding.py new file mode 100644 index 0000000..550e5d7 --- /dev/null +++ b/tests/cmis/test_cmis_runtime_browser_binding.py @@ -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) diff --git a/tests/test_service_api.py b/tests/test_service_api.py index 5ebb92d..0a420d4 100644 --- a/tests/test_service_api.py +++ b/tests/test_service_api.py @@ -654,6 +654,9 @@ def test_service_health_readiness_version_and_openapi_contracts(client) -> None: assert "/api/v1/ready" in paths assert "/api/v1/version" 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/relationships" in paths assert "/api/v1/audit/events" in paths diff --git a/workplans/KONT-WP-0012-cmis-profiled-access-points.md b/workplans/KONT-WP-0012-cmis-profiled-access-points.md index 0703157..c272127 100644 --- a/workplans/KONT-WP-0012-cmis-profiled-access-points.md +++ b/workplans/KONT-WP-0012-cmis-profiled-access-points.md @@ -44,6 +44,8 @@ suite. - `src/kontextual_engine/core/cmis.py` - `tests/cmis/test_cmis_access_profiles.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 @@ -91,7 +93,7 @@ Acceptance: ```task id: KONT-WP-0012-T003 -status: todo +status: done priority: high state_hub_task_id: "b9f5d790-f291-4613-89da-5d47e7887a9e" ```