diff --git a/docs/cmis-1-1-capability-scorecard.md b/docs/cmis-1-1-capability-scorecard.md
index 8d8c303..0ed8e6d 100644
--- a/docs/cmis-1-1-capability-scorecard.md
+++ b/docs/cmis-1-1-capability-scorecard.md
@@ -2,16 +2,19 @@
Date: 2026-05-07
-Evidence update: the 2026-05-08 OpenCMIS TCK compatibility implementation
-resolved the initial Browser Binding session blocker. The latest run,
-`run-20260508T092113Z`, completed the selected baseline with `38` passing
-repository/type cases, one local HTTP transport warning, and `22` object/content
-skips caused by the current non-creatable folder profile. See
-`docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md`.
+Evidence update: the 2026-05-08 WP-0014 OpenCMIS pass moved the selected
+baseline beyond the original folder-creatable skip boundary and through several
+object/content maturity issues. The latest run, `run-20260508T164334Z`, reports
+`repository-type` as warning-only and reduces `object-content` to specific
+semantic gaps: invalid-type exception mapping, bulk update, content deletion,
+change-token conflict handling, copy/create-from-source, and one range
+classification warning. See
+`docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md`.
-The score below remains a product-depth estimate against mature CMIS products;
-the evidence-backed TCK preparation score for the selected baseline is `23.81`
-with `2/9` capability groups covered.
+The score below remains a product-depth estimate against mature CMIS products.
+The selected OpenCMIS baseline still exits as `infrastructure_error`, but the
+failure frontier is now narrow and capability-specific rather than basic
+Browser Binding shape or navigation.
Status: baseline scorecard for the current Browser Binding subset.
@@ -71,9 +74,9 @@ CMIS interoperability importance rather than engine-internal importance.
| Metric | Score |
| --- | ---: |
-| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 42% |
-| Controlled-client Browser Binding usefulness | 58% |
-| Broad commodity CMIS client compatibility | 35% |
+| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 51% |
+| Controlled-client Browser Binding usefulness | 70% |
+| Broad commodity CMIS client compatibility | 46% |
Interpretation: the current CMIS layer is a credible Browser Binding subset for
known clients and profile-specific integrations. It is not yet a broad ECM/CMIS
@@ -83,41 +86,46 @@ replacement surface.
| CMIS capability area | Weight | Current depth | Most worthy contender | Gap basis behind the percentage |
| --- | ---: | ---: | --- | --- |
-| Repository service and repository info | 5 | 75% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.
- Unsupported feature catalog exists.
- Missing exact service-document parity and external TCK evidence. |
-| Type definitions | 6 | 45% | Hyland Alfresco ACS | - Base types and content stream properties exist.
- No mutable types or custom schema/type management.
- No broad property definition model beyond current projected fields. |
-| Navigation service | 8 | 40% | Hyland Alfresco ACS | - Root and folder-scoped children exist.
- Projection-only parents exist.
- Missing `getDescendants`, `getFolderTree`, object-by-path parity, and real filing mutations. |
-| Object read service | 10 | 70% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, and allowable actions exist.
- Missing selector/property-filter fidelity and full Browser Binding response parity.
- Deleted/hidden objects are now correctly not exposed. |
-| Object write service | 8 | 35% | Hyland Alfresco ACS | - `createDocument`, custom metadata updates, content stream set, and delete-request lifecycle exist.
- No createFolder, moveObject, standard `cmis:*` property mutation, or physical delete semantics.
- Delete is intentionally governed, not raw repository removal. |
-| Content stream read/write | 8 | 65% | Hyland Alfresco ACS | - Byte streaming and deduplicating `setContentStream` exist.
- Digest verification and governed access exist.
- Missing append/delete stream, multipart Browser Binding parity, range handling, and client-tested large stream workflows. |
+| Repository service and repository info | 5 | 82% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.
- Unsupported feature catalog exists.
- OpenCMIS `repository-type` is warning-only, with the remaining warning caused by local HTTP. |
+| Type definitions | 6 | 52% | Hyland Alfresco ACS | - Base types and nullable content stream properties exist.
- No mutable types or custom schema/type management.
- Property definition depth remains intentionally narrow. |
+| Navigation service | 8 | 58% | Hyland Alfresco ACS | - Root and folder-scoped children, path lookup, folder parent lookup, and parent path segments work.
- Projection-only parents exist.
- Missing `getDescendants`, `getFolderTree`, and real filing mutations. |
+| Object read service | 10 | 78% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, allowable actions, property filters, and path-addressed Browser Binding reads exist.
- Deleted/hidden objects are now correctly not exposed.
- Remaining read-side gaps are mostly around optional services and exception shape. |
+| Object write service | 8 | 58% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set, and delete-request lifecycle exist.
- No bulk update, copy/create-from-source, broad filing mutation, or physical delete semantics.
- Delete is intentionally governed, not raw repository removal. |
+| Content stream read/write | 8 | 74% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, no-content compatibility streams, and partial body slicing exist.
- Digest verification and governed access exist.
- Remaining gaps are delete-content semantics, append semantics, change-token conflicts, and one range classification warning. |
| Versioning service | 8 | 25% | Hyland Alfresco ACS | - Version properties can be projected from engine versions.
- No checkout/checkin/cancelCheckout/PWC services.
- No version history route or all-versions query behavior. |
| Discovery/query | 8 | 25% | Hyland Alfresco ACS | - Narrow document select subset exists.
- Unsupported joins/order-by return diagnostics.
- Missing CMIS SQL predicates, type joins, full-text, ordering, and rich projection rules. |
| Relationships | 5 | 60% | Hyland Alfresco ACS | - Relationship object projection and source filtering exist.
- Visibility gates prevent protected relationship leakage.
- Missing full relationship service filters, relationship creation through CMIS, and type hierarchy maturity. |
| ACL service | 6 | 35% | Hyland Alfresco ACS | - Discover-only ACL projection exists.
- `applyACL` is blocked as not implemented.
- Missing inherited/direct ACL fidelity, propagation, ACL mutation, and repository principal model. |
| Policy service | 3 | 10% | Hyland Alfresco ACS | - Native policy decisions govern exposure.
- No CMIS policy objects, `applyPolicy`, `removePolicy`, or `getAppliedPolicies` service surface.
- Explicitly unsupported. |
-| Change log | 5 | 55% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.
- Missing full change token durability semantics and richer change event typing.
- Not yet proven against external CMIS clients. |
+| Change log | 5 | 55% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.
- Missing CMIS update-conflict behavior for reused change tokens and richer change event typing.
- Change-token maturity is now directly visible in OpenCMIS object/content. |
| Multi-filing and unfiling | 4 | 25% | Hyland Alfresco ACS | - Projection-only parent maps exist and are useful for navigation.
- Standard CMIS `capabilityMultifiling` is correctly false.
- No add/remove filing mutations or canonical folder membership model. |
| Renditions | 3 | 15% | Hyland Alfresco ACS | - Native representations could become rendition candidates later.
- CMIS rendition capability is currently `none`.
- No rendition taxonomy or rendition stream routes. |
| Retention and hold | 2 | 5% | OpenText / Hyland governance stacks | - Native governance metadata can represent intent later.
- No CMIS retention/hold model or mutation services.
- Explicitly unsupported. |
| Bulk update | 2 | 5% | Hyland Alfresco ACS | - Native batch/error envelopes exist elsewhere in the engine.
- No CMIS `bulkUpdateProperties` behavior.
- Explicitly unsupported. |
-| Browser Binding protocol fidelity | 7 | 45% | Hyland Alfresco ACS | - Browser-style routes and JSON envelopes exist.
- FastAPI route shapes are pragmatic, not complete CMIS Browser Binding selector/action parity.
- Route-level tests skip without optional service dependencies. |
+| Browser Binding protocol fidelity | 7 | 66% | Hyland Alfresco ACS | - Browser-style routes, JSON envelopes, action aliases, multipart forms, path-addressed root routes, property filters, path segments, and range responses exist.
- Optional actions and CMIS exception mapping remain incomplete.
- Route-level CMIS tests run under the service extras and OpenCMIS now exercises object/content deeply. |
| AtomPub binding | 2 | 0% | Hyland Alfresco ACS | - No AtomPub/XML service document or feeds.
- Intentionally deferred until monetized need. |
| Web Services binding | 2 | 0% | Hyland Alfresco ACS | - No SOAP/WSDL stack.
- Intentionally deferred until monetized need. |
-| External conformance evidence | 3 | 35% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation now succeeds against `compat-tck`.
- Selected `repository-type` baseline completes with no failures and one local HTTP warning.
- `object-content` reaches parsed cases but skips because `cmis:folder` is not creatable; broader groups and third-party client matrix are still missing. |
+| External conformance evidence | 3 | 58% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.
- Selected `repository-type` baseline is warning-only.
- `object-content` executes concrete CRUD/content cases and is now blocked by a short list of semantic gaps rather than startup, path, paging, or basic content-read failures. |
-Weighted result from this table: **42%**.
+Weighted result from this table: **51%**.
## Most Important Gaps
1. **External conformance expansion**
- Keep the selected OpenCMIS TCK baseline running against `compat-tck`.
- - Decide whether to add TCK-only `createFolder` support or keep CRUD/content
- skips as a deliberate profile boundary.
- - Expand selected groups after the supported capability boundary is agreed.
+ - Close or explicitly waive the remaining object/content gaps: exception
+ mapping, bulk update, delete content, change-token conflicts, copy, and
+ offset-zero range classification.
+ - Expand selected groups after the supported object/content baseline is
+ stable.
2. **Browser Binding fidelity**
- Align route/action/selector shapes more closely with CMIS Browser Binding.
- Add non-skipped FastAPI route tests in CI with service extras installed.
- Add client smoke tests with Apache Chemistry/OpenCMIS where feasible.
+ - Return CMIS-specific exception classes/statuses instead of generic runtime
+ exceptions where OpenCMIS distinguishes invalid argument, constraint, and
+ update conflict.
3. **Query depth**
- Add a real CMIS SQL subset parser instead of a two-query allowlist.
diff --git a/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md
new file mode 100644
index 0000000..07a8700
--- /dev/null
+++ b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md
@@ -0,0 +1,99 @@
+# CMIS WP-0014 OpenCMIS Evidence - 2026-05-08T15:33:16Z
+
+## Scope
+
+This note records the second WP-0014 maturity pass for the CMIS 1.1 Browser
+Binding adapter. The pass followed the earlier folder/action work and focused
+on OpenCMIS compatibility issues that fit the engine architecture naturally.
+
+## Implemented Since Prior Evidence
+
+- Added Browser Binding `cmisaction=update` as an alias for
+ `updateProperties`.
+- Added scoped `cmisaction=move` support for CMIS-authored workspace documents
+ and adapter-managed workspace folders.
+- Added URL-path Browser Binding routes under `/browser/root/{path}` for object,
+ children, parent, parents, properties, allowable actions, content, and POST
+ actions against path-addressed objects.
+- Normalized stored CMIS content stream media types and emitted explicit
+ `Content-Type` headers so text streams do not acquire an unwanted charset.
+- Accepted metadata-backed standard property updates for `cmis:name`,
+ `cmis:description`, and `cmis:secondaryObjectTypeIds`.
+- Honored `cmis:secondaryObjectTypeIds` during document creation.
+- Rejected invalid `createDocument` type ids, such as `cmis:folder`.
+- Removed non-standard `cmis:path` from document property definitions and
+ document object projections while retaining folder `cmis:path`.
+
+## Local Verification
+
+Focused CMIS suite:
+
+```bash
+.venv/bin/python -m pytest tests/cmis -q
+```
+
+Result:
+
+- `48 passed`
+- Performance monitor warnings appeared on two Browser Binding tests; these
+ should be watched across additional runs before treating them as a regression.
+
+## OpenCMIS Run
+
+The final run used an isolated temporary target on port `8010` to avoid
+colliding with other local services:
+
+```bash
+cd /home/worsch/guide-board
+source /home/worsch/open-cmis-tck/.local/toolchains/env.sh
+PYTHONPATH=src python3 -m guide_board \
+ --extension-dir ../open-cmis-tck \
+ run \
+ --target /tmp/kontextual-cmis-compat-8010.json \
+ --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \
+ --output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z
+```
+
+Result:
+
+- Run ID: `run-20260508T153316Z`
+- Run directory: `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z`
+- Harness status: `infrastructure_error`
+- Maturity scorecard:
+ `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z/reports/cmis-maturity-scorecard.md`
+- Maturity score: `19.05`
+- Coverage: `2/9` groups
+
+## Capability Interpretation
+
+- `repository-type`: improved from failing to `warning` / partial. Repository
+ info and type metadata are now usable enough for the selected baseline, with
+ warnings instead of hard failures.
+- `object-content`: still infrastructure-blocked in guide-board classification,
+ but the blockers are now concrete object/content edge cases rather than
+ Browser Binding startup, folder creation, MIME, or secondary-type basics.
+
+## Remaining Frontier
+
+- `getObjectByPath` still fails in several OpenCMIS child checks for paths such
+ as `/test-folder/test-folder`; this likely needs tighter folder/document
+ child path-segment semantics and cleanup behavior investigation.
+- Creating a document without content still leads OpenCMIS into a missing
+ content-stream exception. We need decide whether CMIS-created no-content
+ documents should expose an empty stream or clearer no-content semantics.
+- Invalid-type errors are now correctly rejected, but OpenCMIS still classifies
+ our HTTP 422 as a runtime warning rather than a clean CMIS constraint
+ response.
+- Folder `cmis:path` still appears when property filters request narrower
+ property sets. Operation-context filtering for properties, ACLs, allowable
+ actions, and path segments remains a compatibility gap.
+- `bulkUpdate`, `deleteContent`, and some change-token tests still receive
+ unsupported-action or validation responses. These should be handled either by
+ natural implementation or sharper unsupported behavior.
+
+## Conclusion
+
+The CMIS adapter foundation is sounder and more standards-shaped after this
+pass. We should keep WP-0014 active for the remaining object/content frontier,
+but the work has moved from "basic Browser Binding compatibility" into specific
+CMIS behavior polish and optional-service boundary decisions.
diff --git a/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md
new file mode 100644
index 0000000..17ff85e
--- /dev/null
+++ b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md
@@ -0,0 +1,109 @@
+# CMIS OpenCMIS TCK Evidence - WP-0014 - 2026-05-08T16:43:34Z
+
+## Run
+
+- Run ID: `run-20260508T164334Z`
+- Harness: `guide-board` with external extension `/home/worsch/open-cmis-tck`
+- Target: `kontextual-cmis-compat`
+- Endpoint under test: `http://127.0.0.1:8010/cmis/compat-tck/browser`
+- Output directory:
+ `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z`
+- Assessment package:
+ `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z/reports/assessment-package.json`
+- Report:
+ `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z/reports/report.md`
+
+Command shape:
+
+```sh
+cd /home/worsch/guide-board
+PYTHONPATH=src python3 -m guide_board \
+ --extension-dir ../open-cmis-tck \
+ run \
+ --target /tmp/kontextual-cmis-compat-8010.json \
+ --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \
+ --output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z
+```
+
+The local Java/Maven toolchain from `/home/worsch/open-cmis-tck/.local/toolchains`
+was supplied in the command environment.
+
+## Internal Verification
+
+- Focused CMIS tests:
+ `.venv/bin/python -m pytest tests/cmis/test_cmis_runtime_browser_binding.py tests/cmis/test_cmis_browser_binding_api.py -q`
+ -> `20 passed`.
+- Full suite:
+ `.venv/bin/python -m pytest -q`
+ -> `160 passed, 14 skipped`.
+
+## OpenCMIS Result Summary
+
+Overall Guide Board status remains `infrastructure_error` because the
+`object-content` group still contains unsupported or incomplete CMIS services.
+
+Normalized group counts:
+
+- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`.
+- `object-content`: `12 info`, `5 skipped`, `3 warning`, `3 fail`,
+ `3 infrastructure_error`.
+
+The sole `repository-type` warning is local HTTP rather than HTTPS:
+`HTTPS is not used. Credentials might be transferred as plain text!`
+
+## Improvements Since `run-20260508T153316Z`
+
+- Fixed Browser Binding parent `relativePathSegment`; OpenCMIS no longer builds
+ invalid paths such as `/folder/folder` for documents.
+- Added Browser Binding property filtering and optional envelope trimming for
+ `filter`, `includeAllowableActions`, `includeACL`, and `includePathSegment`.
+- Changed Browser Binding children `numItems` to report the total child count
+ instead of page length.
+- Added range-aware content responses with sliced bodies, `Content-Length`,
+ `Content-Range`, and `206` for partial requests.
+- Added no-content document compatibility streams while keeping document content
+ stream properties nullable.
+- Added stable object-id behavior for adapter-managed folder rename/update.
+- Added `setContent` and `deleteContent` Browser Binding action aliases over the
+ existing content-stream service boundary.
+- Preserved existing blob deduplication and digest verification paths.
+
+Removed or reduced OpenCMIS frontier items:
+
+- `getObjectByPath` path-segment failures are gone.
+- Create/delete document paging `numItems` failures are gone.
+- Update Smoke Test folder rename/object-id failures are gone.
+- Operation Context ACL delivery warning is gone.
+- Most content range warnings are gone.
+- No-content document retrieval no longer aborts with `Representation not found`.
+
+## Remaining Frontier
+
+These are now the concrete maturity gaps visible in the selected OpenCMIS
+baseline:
+
+- **Invalid type exception mapping**: invalid document/folder type creation
+ returns HTTP `422`, which OpenCMIS reports as `CmisRuntimeException` instead
+ of a narrower CMIS invalid-argument/constraint exception.
+- **Bulk update**: `cmisaction=bulkUpdate` is still unsupported and reports
+ `Unprocessable Entity`.
+- **Delete content semantics**: `deleteContentStream` is accepted through the
+ alias layer, but OpenCMIS still observes content after delete. The adapter
+ needs a stronger natural representation-removal or tombstone model.
+- **Change tokens**: repeated property/content updates with the same change token
+ do not produce CMIS update-conflict behavior yet.
+- **Copy/create-from-source**: `createDocumentFromSource` remains unsupported.
+- **Range classification**: the remaining range warning is for an offset-zero
+ full-stream request being marked as partial.
+
+## Interpretation
+
+The CMIS layer is now past the early Browser Binding and basic object/content
+shape problems. The remaining failures are concentrated in specific CMIS
+service semantics: exception mapping, content deletion, change token conflict
+handling, copy support, and bulk update.
+
+This is a healthy architectural signal: the adapter continues to map naturally
+onto native asset, representation, blob, metadata, policy, and audit services.
+The remaining work should be handled as explicit compatibility choices rather
+than broad ECM reimplementation.
diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py
index 0569558..95bf881 100644
--- a/src/kontextual_engine/api/app.py
+++ b/src/kontextual_engine/api/app.py
@@ -67,7 +67,7 @@ from kontextual_engine.core.cmis import (
cmis_browser_type_definition_by_id,
)
from kontextual_engine.errors import AuthorizationError, KontextualError, NotFoundError, ValidationError
-from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, BlobStorage, PolicyGateway
+from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, BlobRef, BlobStorage, PolicyGateway
from kontextual_engine.services import (
AssetIngestionService,
AssetQueryRequest,
@@ -76,6 +76,7 @@ from kontextual_engine.services import (
ContextEntityQueryRequest,
RelationshipQueryRequest,
RepresentationContentService,
+ RepresentationContentStream,
RetrievalFeedbackRequest,
TransformationRequest,
TransformationService,
@@ -432,35 +433,83 @@ class ServiceRuntime:
details={"access_point_id": access_point_id, "type_id": type_id},
) from exc
- def cmis_browser_root_object(self, access_point_id: str) -> dict[str, Any]:
- return cmis_browser_object(cmis_browser_root_folder(self._cmis_access_point(access_point_id)))
+ def cmis_browser_root_object(
+ self,
+ access_point_id: str,
+ *,
+ property_filter: str | None = None,
+ include_allowable_actions: bool = True,
+ include_acl: bool = True,
+ ) -> dict[str, Any]:
+ return cmis_browser_object(
+ cmis_browser_root_folder(self._cmis_access_point(access_point_id)),
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ )
def cmis_browser_object(
self,
access_point_id: str,
object_id: str | None,
context: OperationContext,
+ *,
+ property_filter: str | None = None,
+ include_allowable_actions: bool = True,
+ include_acl: bool = True,
) -> dict[str, Any]:
if object_id in (None, "", "cmis-root", "root", "/"):
- return self.cmis_browser_root_object(access_point_id)
+ return self.cmis_browser_root_object(
+ access_point_id,
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ )
if object_id.startswith("cmis:folder:"):
folder_path = _cmis_folder_path(object_id) or "/"
mapper = self._cmis_mapper(access_point_id)
+ workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id)
+ if workspace_folder is not None:
+ return cmis_browser_object(
+ self._cmis_workspace_folder_projection(mapper, workspace_folder),
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ )
if not self._cmis_folder_exists(mapper, context, folder_path):
raise NotFoundError(
"CMIS folder not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
- return cmis_browser_object(self._cmis_folder_projection(access_point_id, folder_path))
- return cmis_browser_object(self.cmis_object(access_point_id, object_id, context))
+ return cmis_browser_object(
+ self._cmis_folder_projection(access_point_id, folder_path),
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ )
+ return cmis_browser_object(
+ self.cmis_object(access_point_id, object_id, context),
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ )
def cmis_browser_object_by_path(
self,
access_point_id: str,
path: str,
context: OperationContext,
+ *,
+ property_filter: str | None = None,
+ include_allowable_actions: bool = True,
+ include_acl: bool = True,
) -> dict[str, Any]:
- return cmis_browser_object(self.cmis_object_by_path(access_point_id, path, context))
+ return cmis_browser_object(
+ self.cmis_object_by_path(access_point_id, path, context),
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ )
def cmis_browser_children(
self,
@@ -470,6 +519,10 @@ class ServiceRuntime:
object_id: str | None = None,
skip_count: int = 0,
max_items: int = 100,
+ property_filter: str | None = None,
+ include_allowable_actions: bool = True,
+ include_acl: bool = True,
+ include_path_segment: bool = True,
) -> dict[str, Any]:
children = self.cmis_children(
access_point_id,
@@ -478,15 +531,26 @@ class ServiceRuntime:
skip_count=skip_count,
max_items=max_items,
)
- return cmis_browser_object_in_folder_list(children)
+ return cmis_browser_object_in_folder_list(
+ children,
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ include_path_segment=include_path_segment,
+ )
def cmis_browser_parents(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
+ *,
+ include_relative_path_segment: bool = True,
) -> list[dict[str, Any]]:
- return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context))
+ return cmis_browser_parent_list(
+ self.cmis_object_parents(access_point_id, object_id, context),
+ include_relative_path_segment=include_relative_path_segment,
+ )
def cmis_browser_parent(
self,
@@ -558,6 +622,9 @@ class ServiceRuntime:
raise _cmis_authorization_error(decision, "getObject")
if object_id.startswith("cmis:folder:"):
folder_path = _cmis_folder_path(object_id) or "/"
+ workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id)
+ if workspace_folder is not None:
+ return self._cmis_workspace_folder_projection(mapper, workspace_folder)
if not self._cmis_folder_exists(mapper, context, folder_path):
raise NotFoundError(
"CMIS folder not found",
@@ -569,7 +636,7 @@ class ServiceRuntime:
projection = mapper.map_asset(
asset,
context,
- representations=self.repository.list_representations(asset_id=asset.id),
+ representations=self._cmis_asset_representations(asset),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
@@ -605,7 +672,7 @@ class ServiceRuntime:
projection = mapper.map_asset(
asset,
context,
- representations=self.repository.list_representations(asset_id=asset.id),
+ representations=self._cmis_asset_representations(asset),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
@@ -667,6 +734,44 @@ class ServiceRuntime:
"CMIS object not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
+ if asset.metadata.get("cmis_content_deleted") or not self._cmis_asset_representations(asset):
+ digest = content_digest(b"")
+ representation = AssetRepresentation(
+ asset_id=asset_id,
+ kind=RepresentationKind.SOURCE,
+ media_type="",
+ digest=digest,
+ size_bytes=0,
+ storage_ref="",
+ producer="cmis-empty-content-stream",
+ )
+ decision = PolicyDecision.allow(
+ context.actor.id,
+ "asset.content_stream.read",
+ f"asset:{asset.id}",
+ reason="CMIS document has no content stream; returning an empty compatibility stream.",
+ )
+ return RepresentationContentStream(
+ representation,
+ (),
+ BlobRef(
+ digest=digest,
+ size_bytes=0,
+ storage_key="",
+ storage_ref="",
+ adapter="cmis-empty",
+ media_type=representation.media_type,
+ ),
+ decision,
+ AuditEvent.from_context(
+ "asset.content_stream.read",
+ f"asset:{asset.id}",
+ AuditOutcome.SUCCESS,
+ context,
+ policy_decision=decision,
+ details={"cmis_empty_content_stream": True},
+ ),
+ )
return self.content_service().stream_content(asset_id, context)
def cmis_acl(
@@ -700,7 +805,8 @@ class ServiceRuntime:
if not decision.allowed:
raise _cmis_authorization_error(decision, "getObjectParents")
if object_id.startswith("cmis:folder:"):
- folder_path = _cmis_folder_path(object_id) or "/"
+ workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id)
+ folder_path = workspace_folder.path if workspace_folder is not None else (_cmis_folder_path(object_id) or "/")
if not self._cmis_folder_exists(mapper, context, folder_path):
raise NotFoundError(
"CMIS folder not found",
@@ -710,6 +816,7 @@ class ServiceRuntime:
return {"object_id": object_id, "parents": [], "count": 0}
parent_path = _path_parent(folder_path)
parent = self._cmis_folder_projection(access_point_id, parent_path)
+ parent["relative_path_segment"] = _path_name(folder_path)
return {"object_id": object_id, "parents": [parent], "count": 1}
asset_id = _cmis_asset_id(object_id)
asset = self.repository.get_asset(asset_id)
@@ -724,7 +831,14 @@ class ServiceRuntime:
if explicit_cmis_path
else [parent["path"] for parent in mapper.parent_folders_for_asset(asset)]
)
- parents = [self._cmis_folder_projection(access_point_id, path) for path in dict.fromkeys(parent_paths)]
+ child_segment = _path_name(str(explicit_cmis_path)) if explicit_cmis_path else str(
+ asset.metadata.get("file_name") or asset.title
+ )
+ parents = []
+ for path in dict.fromkeys(parent_paths):
+ parent = self._cmis_folder_projection(access_point_id, path)
+ parent["relative_path_segment"] = child_segment
+ parents.append(parent)
return {"object_id": mapper.asset_object_id(asset.id), "parents": parents, "count": len(parents)}
def cmis_create_folder(
@@ -799,9 +913,16 @@ class ServiceRuntime:
name = payload.get("name") or properties.get("cmis:name")
if not name:
raise ValidationError("CMIS document name is required", details={"operation": "createDocument"})
+ type_id = properties.get("cmis:objectTypeId", payload.get("type_id", CMISBaseType.DOCUMENT.value))
+ asset_type = payload.get("asset_type", "document")
+ if type_id not in {CMISBaseType.DOCUMENT.value, f"kontextual:{asset_type}"}:
+ raise ValidationError(
+ "Unsupported CMIS document type",
+ details={"operation": "createDocument", "type_id": type_id, "supported": [CMISBaseType.DOCUMENT.value]},
+ )
classification = Classification.from_dict(
{
- "asset_type": payload.get("asset_type", "document"),
+ "asset_type": asset_type,
"sensitivity": payload.get("sensitivity", "internal"),
"owner": payload.get("owner"),
"topics": payload.get("topics", []),
@@ -815,7 +936,7 @@ class ServiceRuntime:
representation, _blob, _created = self.content_service().build_representation_from_bytes(
asset_id,
RepresentationKind.SOURCE,
- payload.get("media_type", "text/plain"),
+ _cmis_media_type(payload.get("media_type", "text/plain")),
content,
metadata={"cmis": {"operation": "createDocument"}},
)
@@ -829,18 +950,24 @@ class ServiceRuntime:
metadata_records=[_metadata_record(item) for item in payload.get("metadata_records", [])],
idempotency_key=payload.get("idempotency_key"),
)
+ metadata = dict(result.asset.metadata)
+ if "cmis:description" in properties:
+ metadata["description"] = str(properties.get("cmis:description") or "")
+ if "cmis:secondaryObjectTypeIds" in properties:
+ metadata["cmis_secondary_object_type_ids"] = _cmis_value_list(properties.get("cmis:secondaryObjectTypeIds"))
folder_id = payload.get("folder_id") or payload.get("folderId")
if folder_id:
folder_path = _cmis_folder_path(folder_id) or "/"
asset_path = _normalize_cmis_path(f"{folder_path}/{name}")
metadata = {
- **result.asset.metadata,
+ **metadata,
"cmis_path": asset_path,
"cmis_parent_folder_id": "cmis-root"
if folder_path == "/"
else mapper.folder_object_id(folder_path),
"file_name": str(name),
}
+ if metadata != result.asset.metadata:
self.repository.save_asset(replace(result.asset, metadata=metadata))
return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context)
@@ -868,17 +995,38 @@ class ServiceRuntime:
decision = mapper.access_point.decide_action(CMISAction.UPDATE_PROPERTIES, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "updateProperties")
- asset_id = _cmis_asset_id(object_id)
properties = dict(payload.get("properties", payload))
expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None))
+ if object_id.startswith("cmis:folder:"):
+ return self._cmis_update_workspace_folder(mapper, object_id, properties, context)
+ asset_id = _cmis_asset_id(object_id)
+ asset_metadata_updates: dict[str, Any] = {}
+ title_update: str | None = None
for key, value in properties.items():
if key.startswith("cmis:"):
+ if key == "cmis:name":
+ title_update = str(value).strip()
+ if not title_update:
+ raise ValidationError("CMIS name cannot be empty", details={"operation": "updateProperties"})
+ asset_metadata_updates["file_name"] = title_update
+ continue
+ if key == "cmis:secondaryObjectTypeIds":
+ asset_metadata_updates["cmis_secondary_object_type_ids"] = _cmis_value_list(value)
+ continue
+ if key == "cmis:description":
+ asset_metadata_updates["description"] = str(value) if value is not None else ""
+ continue
raise ValidationError(
"Unsupported CMIS property update",
details={
"property": key,
"operation": "updateProperties",
- "supported": ["kontextual:metadata:"],
+ "supported": [
+ "cmis:name",
+ "cmis:description",
+ "cmis:secondaryObjectTypeIds",
+ "kontextual:metadata:",
+ ],
},
)
self.asset_service().add_metadata_record(
@@ -888,6 +1036,17 @@ class ServiceRuntime:
expected_current_version_id=expected,
)
expected = None
+ if asset_metadata_updates or title_update is not None:
+ asset = self.repository.get_asset(asset_id)
+ metadata = {**asset.metadata, **asset_metadata_updates}
+ if title_update is not None and asset.metadata.get("cmis_path"):
+ current_path = _normalize_cmis_path(str(asset.metadata["cmis_path"]))
+ parent_path = _path_parent(current_path)
+ metadata["cmis_path"] = _normalize_cmis_path(f"{parent_path}/{title_update}")
+ metadata["cmis_parent_folder_id"] = "cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path)
+ self.repository.save_asset(
+ replace(asset, title=title_update or asset.title, metadata=metadata)
+ )
return self.cmis_object(access_point_id, object_id, context)
def cmis_set_content_stream(
@@ -902,10 +1061,15 @@ class ServiceRuntime:
if not decision.allowed:
raise _cmis_authorization_error(decision, "setContentStream")
asset_id = _cmis_asset_id(object_id)
+ asset = self.repository.get_asset(asset_id)
+ if asset.metadata.get("cmis_content_deleted"):
+ metadata = dict(asset.metadata)
+ metadata.pop("cmis_content_deleted", None)
+ self.repository.save_asset(replace(asset, metadata=metadata))
self.content_service().add_representation_from_bytes(
asset_id,
payload.get("kind", RepresentationKind.SOURCE.value),
- payload.get("media_type", "text/plain"),
+ _cmis_media_type(payload.get("media_type", "text/plain")),
payload.get("content", ""),
context,
expected_current_version_id=payload.get("expected_current_version_id"),
@@ -913,6 +1077,84 @@ class ServiceRuntime:
)
return self.cmis_object(access_point_id, object_id, context)
+ def cmis_delete_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.SET_CONTENT_STREAM, context, resource=object_id)
+ if not decision.allowed:
+ raise _cmis_authorization_error(decision, "deleteContentStream")
+ asset_id = _cmis_asset_id(object_id)
+ asset = self.repository.get_asset(asset_id)
+ if not mapper.access_point.exposes_asset(asset, context):
+ raise NotFoundError(
+ "CMIS object not found",
+ details={"object_id": object_id, "access_point_id": access_point_id},
+ )
+ self.repository.save_asset(replace(asset, metadata={**asset.metadata, "cmis_content_deleted": True}))
+ return self.cmis_object(access_point_id, object_id, context)
+
+ def cmis_move_object(
+ self,
+ access_point_id: str,
+ object_id: str,
+ payload: dict[str, Any],
+ context: OperationContext,
+ ) -> dict[str, Any]:
+ mapper = self._cmis_mapper(access_point_id)
+ decision = mapper.access_point.decide_action(CMISAction.MOVE_OBJECT, context, resource=object_id)
+ if not decision.allowed:
+ raise _cmis_authorization_error(decision, "moveObject")
+ target_folder_id = payload.get("targetFolderId") or payload.get("target_folder_id")
+ if not target_folder_id:
+ raise ValidationError("CMIS target folder id is required", details={"operation": "moveObject"})
+ target_path = _cmis_folder_path(str(target_folder_id)) or "/"
+ if not self._cmis_folder_exists(mapper, context, target_path):
+ raise NotFoundError(
+ "CMIS target folder not found",
+ details={"operation": "moveObject", "target_folder_id": target_folder_id},
+ )
+ source_folder_id = payload.get("sourceFolderId") or payload.get("source_folder_id")
+ source_path = _cmis_folder_path(str(source_folder_id)) if source_folder_id else None
+
+ if object_id.startswith("cmis:folder:"):
+ return self._cmis_move_workspace_folder(mapper, object_id, target_path, source_path, context)
+
+ asset_id = _cmis_asset_id(object_id)
+ asset = self.repository.get_asset(asset_id)
+ if not mapper.access_point.exposes_asset(asset, context):
+ raise NotFoundError(
+ "CMIS object not found",
+ details={"object_id": object_id, "access_point_id": access_point_id},
+ )
+ current_path = _normalize_cmis_path(str(asset.metadata.get("cmis_path") or mapper.asset_path(asset)))
+ current_parent = _path_parent(current_path)
+ if source_path and source_path != current_parent:
+ raise ValidationError(
+ "CMIS source folder does not match current object parent",
+ details={
+ "operation": "moveObject",
+ "source_folder_id": source_folder_id,
+ "current_parent": current_parent,
+ },
+ )
+ file_name = str(asset.metadata.get("file_name") or _path_name(current_path) or asset.title)
+ new_path = _normalize_cmis_path(f"{target_path}/{file_name}")
+ if new_path == current_path:
+ return self.cmis_object(access_point_id, object_id, context)
+ self._validate_cmis_path_available(mapper, context, new_path, excluding_asset_id=asset_id)
+ metadata = {
+ **asset.metadata,
+ "cmis_path": new_path,
+ "cmis_parent_folder_id": "cmis-root" if target_path == "/" else mapper.folder_object_id(target_path),
+ "file_name": file_name,
+ }
+ self.repository.save_asset(replace(asset, metadata=metadata))
+ return self.cmis_object(access_point_id, object_id, context)
+
def representation_content_stream(
self,
asset_id: str,
@@ -937,7 +1179,8 @@ class ServiceRuntime:
if not decision.allowed:
raise _cmis_authorization_error(decision, "deleteObject")
if object_id.startswith("cmis:folder:"):
- folder_path = _cmis_folder_path(object_id) or "/"
+ workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id)
+ folder_path = workspace_folder.path if workspace_folder is not None else (_cmis_folder_path(object_id) or "/")
if folder_path == "/":
raise ValidationError("CMIS root folder cannot be deleted", details={"operation": "deleteObject"})
folders = self._cmis_workspace_folder_map(access_point_id)
@@ -985,7 +1228,8 @@ class ServiceRuntime:
decision = mapper.access_point.decide_action(CMISAction.DELETE_TREE, context, resource=object_id)
if not decision.allowed:
raise _cmis_authorization_error(decision, "deleteTree")
- folder_path = _cmis_folder_path(object_id)
+ workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id)
+ folder_path = workspace_folder.path if workspace_folder is not None else _cmis_folder_path(object_id)
if folder_path in (None, "/"):
raise ValidationError("CMIS root folder cannot be deleteTree target", details={"operation": "deleteTree"})
@@ -1191,7 +1435,7 @@ class ServiceRuntime:
projection = mapper.map_asset(
asset,
context,
- representations=self.repository.list_representations(asset_id=asset.id),
+ representations=self._cmis_asset_representations(asset),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
@@ -1214,6 +1458,21 @@ class ServiceRuntime:
def _cmis_workspace_folder_map(self, access_point_id: str) -> dict[str, CMISWorkspaceFolder]:
return self.cmis_workspace_folders.setdefault(access_point_id, {})
+ def _cmis_asset_representations(self, asset) -> list[AssetRepresentation]:
+ if asset.metadata.get("cmis_content_deleted"):
+ return []
+ return self.repository.list_representations(asset_id=asset.id)
+
+ def _cmis_workspace_folder_by_object_id(
+ self,
+ access_point_id: str,
+ object_id: str,
+ ) -> CMISWorkspaceFolder | None:
+ for folder in self._cmis_workspace_folder_map(access_point_id).values():
+ if folder.object_id == object_id:
+ return folder
+ return None
+
def _cmis_folder_projection(self, access_point_id: str, folder_path: str) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id)
normalized = _normalize_cmis_path(folder_path)
@@ -1224,6 +1483,208 @@ class ServiceRuntime:
return self._cmis_workspace_folder_projection(mapper, folder)
return mapper.folder_projection(normalized)
+ def _validate_cmis_path_available(
+ self,
+ mapper: CMISDomainMapper,
+ context: OperationContext,
+ path: str,
+ *,
+ excluding_asset_id: str | None = None,
+ excluding_folder_path: str | None = None,
+ ) -> None:
+ normalized = _normalize_cmis_path(path)
+ excluded_folder = _normalize_cmis_path(excluding_folder_path) if excluding_folder_path else None
+ if normalized != excluded_folder and normalized in self._cmis_workspace_folder_map(mapper.access_point.access_point_id):
+ raise ValidationError(
+ "CMIS path already exists",
+ details={"operation": "moveObject", "path": normalized, "kind": "folder"},
+ )
+ for asset in self.repository.list_assets():
+ if excluding_asset_id and asset.id == excluding_asset_id:
+ continue
+ if not mapper.access_point.exposes_asset(asset, context):
+ continue
+ if normalized in mapper.asset_paths(asset):
+ raise ValidationError(
+ "CMIS path already exists",
+ details={"operation": "moveObject", "path": normalized, "kind": "document"},
+ )
+
+ def _cmis_update_workspace_folder(
+ self,
+ mapper: CMISDomainMapper,
+ object_id: str,
+ properties: dict[str, Any],
+ context: OperationContext,
+ ) -> dict[str, Any]:
+ folder = self._cmis_workspace_folder_by_object_id(mapper.access_point.access_point_id, object_id)
+ if folder is None:
+ folder_path = _cmis_folder_path(object_id)
+ if folder_path in (None, "/"):
+ raise ValidationError("CMIS root folder cannot be updated", details={"operation": "updateProperties"})
+ folder = self._cmis_workspace_folder_map(mapper.access_point.access_point_id).get(folder_path)
+ if folder is None:
+ raise NotFoundError(
+ "CMIS folder not found",
+ details={"operation": "updateProperties", "object_id": object_id},
+ )
+
+ supported = {"cmis:name", "cmis:description", "cmis:secondaryObjectTypeIds"}
+ unsupported = sorted(key for key in properties if key.startswith("cmis:") and key not in supported)
+ if unsupported:
+ raise ValidationError(
+ "Unsupported CMIS folder property update",
+ details={"operation": "updateProperties", "unsupported": unsupported, "supported": sorted(supported)},
+ )
+
+ new_name = properties.get("cmis:name")
+ if new_name is None or str(new_name) == folder.name:
+ updated = replace(folder, updated_at=utc_now().isoformat())
+ self._cmis_workspace_folder_map(mapper.access_point.access_point_id)[folder.path] = updated
+ return self._cmis_workspace_folder_projection(mapper, updated)
+
+ name = str(new_name).strip()
+ if not name:
+ raise ValidationError("CMIS name cannot be empty", details={"operation": "updateProperties"})
+ old_path = folder.path
+ parent_path = _path_parent(old_path)
+ new_path = _normalize_cmis_path(f"{parent_path}/{name}")
+ if new_path != old_path:
+ self._validate_cmis_path_available(
+ mapper,
+ context,
+ new_path,
+ excluding_folder_path=old_path,
+ )
+ folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id)
+ moving = {
+ path: value
+ for path, value in folders.items()
+ if path == old_path or _path_contains(old_path, path)
+ }
+ now = utc_now().isoformat()
+ for path in sorted(moving, key=lambda item: item.count("/"), reverse=True):
+ del folders[path]
+ for path, value in sorted(moving.items(), key=lambda item: item[0].count("/")):
+ suffix = path.removeprefix(old_path)
+ moved_path = _normalize_cmis_path(f"{new_path}{suffix}")
+ moved_parent = _path_parent(moved_path)
+ folders[moved_path] = replace(
+ value,
+ path=moved_path,
+ name=_path_name(moved_path),
+ parent_id="cmis-root" if moved_parent == "/" else mapper.folder_object_id(moved_parent),
+ updated_at=now,
+ )
+
+ for asset in self.repository.list_assets():
+ explicit_path = asset.metadata.get("cmis_path")
+ if not explicit_path:
+ continue
+ asset_path = _normalize_cmis_path(str(explicit_path))
+ if not _path_contains(old_path, asset_path):
+ continue
+ suffix = asset_path.removeprefix(old_path)
+ moved_asset_path = _normalize_cmis_path(f"{new_path}{suffix}")
+ moved_parent = _path_parent(moved_asset_path)
+ self.repository.save_asset(
+ replace(
+ asset,
+ metadata={
+ **asset.metadata,
+ "cmis_path": moved_asset_path,
+ "cmis_parent_folder_id": "cmis-root"
+ if moved_parent == "/"
+ else mapper.folder_object_id(moved_parent),
+ },
+ )
+ )
+ return self._cmis_workspace_folder_projection(mapper, folders[new_path])
+
+ def _cmis_move_workspace_folder(
+ self,
+ mapper: CMISDomainMapper,
+ object_id: str,
+ target_path: str,
+ source_path: str | None,
+ context: OperationContext,
+ ) -> dict[str, Any]:
+ workspace_folder = self._cmis_workspace_folder_by_object_id(mapper.access_point.access_point_id, object_id)
+ folder_path = workspace_folder.path if workspace_folder is not None else _cmis_folder_path(object_id)
+ if folder_path in (None, "/"):
+ raise ValidationError("CMIS root folder cannot be moved", details={"operation": "moveObject"})
+ folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id)
+ folder = folders.get(folder_path)
+ if folder is None:
+ raise ValidationError(
+ "Only adapter-managed CMIS workspace folders can be moved",
+ details={"operation": "moveObject", "object_id": object_id},
+ )
+ current_parent = _path_parent(folder_path)
+ if source_path and source_path != current_parent:
+ raise ValidationError(
+ "CMIS source folder does not match current object parent",
+ details={
+ "operation": "moveObject",
+ "source_folder_id": source_path,
+ "current_parent": current_parent,
+ },
+ )
+ new_path = _normalize_cmis_path(f"{target_path}/{folder.name}")
+ if new_path == folder_path:
+ return self._cmis_workspace_folder_projection(mapper, folder)
+ if _path_contains(folder_path, target_path):
+ raise ValidationError(
+ "CMIS folder cannot be moved below itself",
+ details={"operation": "moveObject", "object_id": object_id, "target_path": target_path},
+ )
+ self._validate_cmis_path_available(mapper, context, new_path, excluding_folder_path=folder_path)
+
+ now = utc_now().isoformat()
+ moving = {
+ path: value
+ for path, value in folders.items()
+ if path == folder_path or _path_contains(folder_path, path)
+ }
+ for path in sorted(moving, key=lambda item: item.count("/"), reverse=True):
+ del folders[path]
+ for path, value in sorted(moving.items(), key=lambda item: item[0].count("/")):
+ suffix = path.removeprefix(folder_path)
+ moved_path = _normalize_cmis_path(f"{new_path}{suffix}")
+ parent_path = _path_parent(moved_path)
+ folders[moved_path] = replace(
+ value,
+ object_id=mapper.folder_object_id(moved_path),
+ path=moved_path,
+ name=_path_name(moved_path),
+ parent_id="cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path),
+ updated_at=now,
+ )
+
+ for asset in self.repository.list_assets():
+ explicit_path = asset.metadata.get("cmis_path")
+ if not explicit_path:
+ continue
+ asset_path = _normalize_cmis_path(str(explicit_path))
+ if not _path_contains(folder_path, asset_path):
+ continue
+ suffix = asset_path.removeprefix(folder_path)
+ moved_asset_path = _normalize_cmis_path(f"{new_path}{suffix}")
+ parent_path = _path_parent(moved_asset_path)
+ self.repository.save_asset(
+ replace(
+ asset,
+ metadata={
+ **asset.metadata,
+ "cmis_path": moved_asset_path,
+ "cmis_parent_folder_id": "cmis-root"
+ if parent_path == "/"
+ else mapper.folder_object_id(parent_path),
+ },
+ )
+ )
+ return self._cmis_workspace_folder_projection(mapper, folders[new_path])
+
def _cmis_folder_exists(
self,
mapper: CMISDomainMapper,
@@ -1299,7 +1760,7 @@ class ServiceRuntime:
projection = mapper.map_asset(
asset,
context,
- representations=self.repository.list_representations(asset_id=asset.id),
+ representations=self._cmis_asset_representations(asset),
versions=self.repository.list_versions(asset.id),
relationship_ids=[
f"cmis:relationship:{relationship.relationship_id}"
@@ -2726,6 +3187,71 @@ def create_app(runtime: ServiceRuntime | None = None):
str(request.url_for("cmis_browser_root", access_point_id=access_point_id)),
)
+ def browser_include(value: bool | None, *, default: bool = True) -> bool:
+ return default if value is None else value
+
+ def browser_content_response(
+ result,
+ *,
+ offset: int | None = None,
+ length: int | None = None,
+ range_header: str | None = None,
+ ) -> StreamingResponse:
+ representation = result.representation
+ if range_header and offset is None and length is None and range_header.startswith("bytes="):
+ range_spec = range_header.removeprefix("bytes=").split(",", 1)[0]
+ start_text, _, end_text = range_spec.partition("-")
+ if start_text.strip().isdigit():
+ offset = int(start_text)
+ if end_text.strip().isdigit():
+ end = int(end_text)
+ length = max(end - offset + 1, 0)
+ start = max(offset or 0, 0)
+ requested_length = None if length is None else max(length, 0)
+ is_partial = offset is not None or length is not None or bool(range_header)
+ content_length = max(representation.size_bytes - start, 0)
+ if requested_length is not None:
+ content_length = min(content_length, requested_length)
+
+ def chunks():
+ skip = start
+ remaining = requested_length
+ for chunk in result.chunks:
+ if skip:
+ if len(chunk) <= skip:
+ skip -= len(chunk)
+ continue
+ chunk = chunk[skip:]
+ skip = 0
+ if remaining is None:
+ yield chunk
+ continue
+ if remaining <= 0:
+ break
+ part = chunk[:remaining]
+ remaining -= len(part)
+ if part:
+ yield part
+ if remaining <= 0:
+ break
+
+ headers = {
+ "Content-Length": str(content_length),
+ "ETag": representation.digest,
+ "X-Kontextual-Representation-Id": representation.representation_id,
+ "X-Kontextual-Storage-Ref": representation.storage_ref or "",
+ }
+ if representation.media_type:
+ headers["Content-Type"] = representation.media_type
+ if is_partial:
+ end = start + content_length - 1 if content_length else start
+ headers["Content-Range"] = f"bytes {start}-{end}/{representation.size_bytes}"
+ return StreamingResponse(
+ chunks(),
+ status_code=206 if is_partial else 200,
+ headers=headers,
+ )
+
def unsupported_browser_selector(selector: str | None) -> dict[str, Any]:
raise ValidationError(
"Unsupported CMIS Browser Binding selector",
@@ -2764,7 +3290,10 @@ def create_app(runtime: ServiceRuntime | None = None):
payload[field_name] = file_value["content"]
if field_name in {"content", "contentStream", "file"} or "content" not in payload:
payload["content"] = file_value["content"]
- payload.setdefault("media_type", file_value.get("content_type") or "application/octet-stream")
+ payload.setdefault(
+ "media_type",
+ _cmis_media_type(file_value.get("content_type") or "application/octet-stream"),
+ )
payload.setdefault("content_filename", file_value.get("filename"))
else:
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
@@ -2816,16 +3345,26 @@ def create_app(runtime: ServiceRuntime | None = None):
if not object_id:
raise ValidationError("CMIS object id is required", details={"operation": "deleteTree"})
return response(runtime.cmis_delete_tree, access_point_id, object_id, payload, context)
- if action == "updateProperties":
+ if action in {"updateProperties", "update"}:
if not object_id:
raise ValidationError("CMIS object id is required", details={"operation": "updateProperties"})
response(runtime.cmis_update_properties, access_point_id, object_id, payload, context)
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
- if action == "setContentStream":
+ if action == "move":
+ if not object_id:
+ raise ValidationError("CMIS object id is required", details={"operation": "moveObject"})
+ moved = response(runtime.cmis_move_object, access_point_id, object_id, payload, context)
+ return cmis_browser_object(moved)
+ if action in {"setContentStream", "setContent"}:
if not object_id:
raise ValidationError("CMIS object id is required", details={"operation": "setContentStream"})
response(runtime.cmis_set_content_stream, access_point_id, object_id, payload, context)
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
+ if action in {"deleteContent", "deleteContentStream"}:
+ if not object_id:
+ raise ValidationError("CMIS object id is required", details={"operation": "deleteContentStream"})
+ response(runtime.cmis_delete_content_stream, access_point_id, object_id, context)
+ return response(runtime.cmis_browser_object, access_point_id, object_id, context)
raise ValidationError(
"Unsupported CMIS Browser Binding action",
details={
@@ -2837,7 +3376,12 @@ def create_app(runtime: ServiceRuntime | None = None):
"deleteObject",
"deleteTree",
"updateProperties",
+ "update",
+ "move",
"setContentStream",
+ "setContent",
+ "deleteContent",
+ "deleteContentStream",
],
},
)
@@ -2911,14 +3455,38 @@ def create_app(runtime: ServiceRuntime | None = None):
cmisselector: str | None = Query(None),
objectId: str | None = Query(None),
path: str | None = Query(None),
+ propertyFilter: str | None = Query(None, alias="filter"),
+ includeAllowableActions: bool | None = Query(None),
+ includeACL: bool | None = Query(None),
+ includePathSegment: bool | None = Query(None),
+ includeRelativePathSegment: bool | None = Query(None),
+ offset: int | None = Query(None),
+ length: int | None = Query(None),
+ range_header: str | None = Header(None, alias="Range"),
skipCount: int = Query(0),
maxItems: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> Any:
if cmisselector in (None, "", "object"):
if path and not objectId:
- return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)
- return response(runtime.cmis_browser_object, access_point_id, objectId, context)
+ return response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ path,
+ context,
+ property_filter=propertyFilter,
+ include_allowable_actions=browser_include(includeAllowableActions),
+ include_acl=browser_include(includeACL, default=False),
+ )
+ return response(
+ runtime.cmis_browser_object,
+ access_point_id,
+ objectId,
+ context,
+ property_filter=propertyFilter,
+ include_allowable_actions=browser_include(includeAllowableActions),
+ include_acl=browser_include(includeACL, default=False),
+ )
if cmisselector == "children":
return response(
runtime.cmis_browser_children,
@@ -2927,6 +3495,10 @@ def create_app(runtime: ServiceRuntime | None = None):
object_id=objectId,
skip_count=skipCount,
max_items=maxItems,
+ property_filter=propertyFilter,
+ include_allowable_actions=browser_include(includeAllowableActions),
+ include_acl=browser_include(includeACL, default=False),
+ include_path_segment=browser_include(includePathSegment),
)
if cmisselector == "parent":
if not objectId:
@@ -2935,32 +3507,164 @@ def create_app(runtime: ServiceRuntime | None = None):
if cmisselector == "parents":
if not objectId:
return []
- return response(runtime.cmis_browser_parents, access_point_id, objectId, context)
+ return response(
+ runtime.cmis_browser_parents,
+ access_point_id,
+ objectId,
+ context,
+ include_relative_path_segment=browser_include(includeRelativePathSegment),
+ )
if cmisselector == "properties":
if path and not objectId:
- return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["properties"]
- return response(runtime.cmis_browser_object, access_point_id, objectId, context)["properties"]
+ return response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ path,
+ context,
+ property_filter=propertyFilter,
+ include_allowable_actions=False,
+ include_acl=False,
+ )["properties"]
+ return response(
+ runtime.cmis_browser_object,
+ access_point_id,
+ objectId,
+ context,
+ property_filter=propertyFilter,
+ include_allowable_actions=False,
+ include_acl=False,
+ )["properties"]
if cmisselector == "allowableActions":
if path and not objectId:
- return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["allowableActions"]
- return response(runtime.cmis_browser_object, access_point_id, objectId, context)["allowableActions"]
+ return response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ path,
+ context,
+ include_allowable_actions=True,
+ )["allowableActions"]
+ return response(
+ runtime.cmis_browser_object,
+ access_point_id,
+ objectId,
+ context,
+ include_allowable_actions=True,
+ )["allowableActions"]
if cmisselector == "policies":
return []
if cmisselector == "content":
if not objectId:
return unsupported_browser_selector(cmisselector)
result = response(runtime.cmis_content_stream_bytes, access_point_id, objectId, context)
- representation = result.representation
- return StreamingResponse(
- result.chunks,
- media_type=representation.media_type,
- headers={
- "Content-Length": str(representation.size_bytes),
- "ETag": representation.digest,
- "X-Kontextual-Representation-Id": representation.representation_id,
- "X-Kontextual-Storage-Ref": representation.storage_ref or "",
- },
+ return browser_content_response(result, offset=offset, length=length, range_header=range_header)
+ return unsupported_browser_selector(cmisselector)
+
+ @app.get("/cmis/{access_point_id}/browser/root/{object_path:path}", tags=["cmis"])
+ def cmis_browser_root_path(
+ access_point_id: str,
+ object_path: str,
+ cmisselector: str | None = Query(None),
+ objectId: str | None = Query(None),
+ propertyFilter: str | None = Query(None, alias="filter"),
+ includeAllowableActions: bool | None = Query(None),
+ includeACL: bool | None = Query(None),
+ includePathSegment: bool | None = Query(None),
+ includeRelativePathSegment: bool | None = Query(None),
+ offset: int | None = Query(None),
+ length: int | None = Query(None),
+ range_header: str | None = Header(None, alias="Range"),
+ skipCount: int = Query(0),
+ maxItems: int = Query(100),
+ context: OperationContext = Depends(context_from_headers),
+ ) -> Any:
+ path = _normalize_cmis_path(object_path)
+ browser_object: dict[str, Any] | None = None
+ resolution_object: dict[str, Any] | None = None
+
+ def object_by_path() -> dict[str, Any]:
+ nonlocal browser_object
+ if browser_object is None:
+ browser_object = response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ path,
+ context,
+ property_filter=propertyFilter,
+ include_allowable_actions=browser_include(includeAllowableActions),
+ include_acl=browser_include(includeACL, default=False),
+ )
+ return browser_object
+
+ def object_for_resolution() -> dict[str, Any]:
+ nonlocal resolution_object
+ if resolution_object is None:
+ resolution_object = response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ path,
+ context,
+ include_allowable_actions=False,
+ include_acl=False,
+ )
+ return resolution_object
+
+ def resolved_object_id() -> str | None:
+ if objectId:
+ return objectId
+ object_id_property = object_for_resolution().get("properties", {}).get("cmis:objectId", {})
+ if isinstance(object_id_property, dict):
+ return object_id_property.get("value")
+ return None
+
+ if cmisselector in (None, "", "object"):
+ return object_by_path()
+ if cmisselector == "children":
+ return response(
+ runtime.cmis_browser_children,
+ access_point_id,
+ context,
+ object_id=resolved_object_id(),
+ skip_count=skipCount,
+ max_items=maxItems,
+ property_filter=propertyFilter,
+ include_allowable_actions=browser_include(includeAllowableActions),
+ include_acl=browser_include(includeACL, default=False),
+ include_path_segment=browser_include(includePathSegment),
)
+ if cmisselector == "parent":
+ object_id = resolved_object_id()
+ if not object_id:
+ return unsupported_browser_selector(cmisselector)
+ return response(runtime.cmis_browser_parent, access_point_id, object_id, context)
+ if cmisselector == "parents":
+ object_id = resolved_object_id()
+ if not object_id:
+ return []
+ return response(
+ runtime.cmis_browser_parents,
+ access_point_id,
+ object_id,
+ context,
+ include_relative_path_segment=browser_include(includeRelativePathSegment),
+ )
+ if cmisselector == "properties":
+ return object_by_path()["properties"]
+ if cmisselector == "allowableActions":
+ return response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ path,
+ context,
+ include_allowable_actions=True,
+ )["allowableActions"]
+ if cmisselector == "policies":
+ return []
+ if cmisselector == "content":
+ object_id = resolved_object_id()
+ if not object_id:
+ return unsupported_browser_selector(cmisselector)
+ result = response(runtime.cmis_content_stream_bytes, access_point_id, object_id, context)
+ return browser_content_response(result, offset=offset, length=length, range_header=range_header)
return unsupported_browser_selector(cmisselector)
@app.post("/cmis/{access_point_id}/browser/root", tags=["cmis"])
@@ -2977,6 +3681,32 @@ def create_app(runtime: ServiceRuntime | None = None):
context=context,
)
+ @app.post("/cmis/{access_point_id}/browser/root/{object_path:path}", tags=["cmis"])
+ async def cmis_browser_root_path_action(
+ access_point_id: str,
+ object_path: str,
+ request: Request,
+ objectId: str | None = Query(None),
+ context: OperationContext = Depends(context_from_headers),
+ ) -> Any:
+ default_object_id = objectId
+ if not default_object_id:
+ browser_object = response(
+ runtime.cmis_browser_object_by_path,
+ access_point_id,
+ _normalize_cmis_path(object_path),
+ context,
+ )
+ object_id_property = browser_object.get("properties", {}).get("cmis:objectId", {})
+ if isinstance(object_id_property, dict):
+ default_object_id = object_id_property.get("value")
+ return await cmis_browser_post_action(
+ access_point_id,
+ request,
+ default_object_id=default_object_id,
+ context=context,
+ )
+
@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)
@@ -3018,20 +3748,13 @@ def create_app(runtime: ServiceRuntime | None = None):
def cmis_content_stream_bytes(
access_point_id: str,
object_id: str,
+ offset: int | None = Query(None),
+ length: int | None = Query(None),
+ range_header: str | None = Header(None, alias="Range"),
context: OperationContext = Depends(context_from_headers),
) -> Any:
result = response(runtime.cmis_content_stream_bytes, access_point_id, object_id, context)
- representation = result.representation
- return StreamingResponse(
- result.chunks,
- media_type=representation.media_type,
- headers={
- "Content-Length": str(representation.size_bytes),
- "ETag": representation.digest,
- "X-Kontextual-Representation-Id": representation.representation_id,
- "X-Kontextual-Storage-Ref": representation.storage_ref or "",
- },
- )
+ return browser_content_response(result, offset=offset, length=length, range_header=range_header)
@app.get("/cmis/{access_point_id}/browser/acl/{object_id:path}", tags=["cmis"])
def cmis_acl(
@@ -3585,6 +4308,19 @@ def _normalize_cmis_path(path: str) -> str:
return "/" + "/".join(parts)
+def _cmis_media_type(value: Any) -> str:
+ media_type = str(value or "application/octet-stream").split(";", 1)[0].strip()
+ return media_type or "application/octet-stream"
+
+
+def _cmis_value_list(value: Any) -> list[str]:
+ if value is None or value == "":
+ return []
+ if isinstance(value, (list, tuple, set)):
+ return [str(item) for item in value if item not in (None, "")]
+ return [str(value)]
+
+
def _path_parent(path: str) -> str:
parts = _normalize_cmis_path(path).strip("/").split("/")
if len(parts) <= 1:
@@ -3650,16 +4386,21 @@ def _cmis_browser_properties(payload: dict[str, Any]) -> dict[str, Any]:
properties = dict(payload.get("properties", {}))
property_ids: dict[str, str] = {}
property_values: dict[str, Any] = {}
+ property_value_lists: dict[str, list[Any]] = {}
for key, value in payload.items():
if key.startswith("propertyId["):
index = key[len("propertyId[") :].split("]", 1)[0]
property_ids[index] = str(value)
elif key.startswith("propertyValue["):
- index = key[len("propertyValue[") :].split("]", 1)[0]
- property_values[index] = value
+ remainder = key[len("propertyValue[") :]
+ index, tail = remainder.split("]", 1)
+ if tail.startswith("["):
+ property_value_lists.setdefault(index, []).extend(value if isinstance(value, list) else [value])
+ else:
+ property_values[index] = value
for index, property_id in property_ids.items():
if property_id:
- properties[property_id] = property_values.get(index)
+ properties[property_id] = property_value_lists.get(index, property_values.get(index))
if "cmis:name" not in properties and payload.get("name"):
properties["cmis:name"] = payload["name"]
if "cmis:objectTypeId" not in properties and payload.get("type_id"):
diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py
index cc4dd97..582f43f 100644
--- a/src/kontextual_engine/core/cmis.py
+++ b/src/kontextual_engine/core/cmis.py
@@ -58,6 +58,7 @@ class CMISAction(str, Enum):
CREATE_FOLDER = "create_folder"
CREATE_DOCUMENT = "create_document"
UPDATE_PROPERTIES = "update_properties"
+ MOVE_OBJECT = "move_object"
DELETE_OBJECT = "delete_object"
DELETE_TREE = "delete_tree"
SET_CONTENT_STREAM = "set_content_stream"
@@ -89,6 +90,7 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = {
CMISAction.CREATE_FOLDER: CMISCapability.OBJECT_WRITE,
CMISAction.CREATE_DOCUMENT: CMISCapability.OBJECT_WRITE,
CMISAction.UPDATE_PROPERTIES: CMISCapability.OBJECT_WRITE,
+ CMISAction.MOVE_OBJECT: CMISCapability.OBJECT_WRITE,
CMISAction.DELETE_OBJECT: CMISCapability.OBJECT_WRITE,
CMISAction.DELETE_TREE: CMISCapability.OBJECT_WRITE,
CMISAction.SET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_WRITE,
@@ -111,6 +113,7 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset(
CMISAction.CREATE_FOLDER,
CMISAction.CREATE_DOCUMENT,
CMISAction.UPDATE_PROPERTIES,
+ CMISAction.MOVE_OBJECT,
CMISAction.DELETE_OBJECT,
CMISAction.DELETE_TREE,
CMISAction.SET_CONTENT_STREAM,
@@ -120,6 +123,7 @@ MUTATION_ACTIONS = {
CMISAction.CREATE_DOCUMENT,
CMISAction.CREATE_FOLDER,
CMISAction.UPDATE_PROPERTIES,
+ CMISAction.MOVE_OBJECT,
CMISAction.DELETE_OBJECT,
CMISAction.DELETE_TREE,
CMISAction.SET_CONTENT_STREAM,
@@ -884,7 +888,6 @@ class CMISDomainMapper:
"cmis:changeToken": asset.current_version_id,
"cmis:description": asset.metadata.get("description", asset.title),
"cmis:secondaryObjectTypeIds": list(asset.metadata.get("cmis_secondary_object_type_ids", ())),
- "cmis:path": self.asset_path(asset),
"cmis:contentStreamLength": content_stream.get("length") if content_stream else None,
"cmis:contentStreamMimeType": content_stream.get("mime_type") if content_stream else None,
"cmis:contentStreamFileName": content_stream.get("file_name") if content_stream else None,
@@ -905,6 +908,10 @@ class CMISDomainMapper:
compacted.setdefault("cmis:createdBy", "system")
compacted.setdefault("cmis:lastModifiedBy", "system")
compacted.setdefault("cmis:secondaryObjectTypeIds", [])
+ compacted.setdefault("cmis:contentStreamLength", None)
+ compacted.setdefault("cmis:contentStreamMimeType", None)
+ compacted.setdefault("cmis:contentStreamFileName", None)
+ compacted.setdefault("cmis:contentStreamId", None)
compacted.setdefault("kontextual:owner", "")
compacted.setdefault("kontextual:topics", [])
compacted.setdefault("kontextual:reviewState", "")
@@ -980,6 +987,7 @@ class CMISDomainMapper:
CMISAction.GET_RELATIONSHIPS,
CMISAction.GET_OBJECT_PARENTS,
CMISAction.UPDATE_PROPERTIES,
+ CMISAction.MOVE_OBJECT,
CMISAction.DELETE_OBJECT,
CMISAction.SET_CONTENT_STREAM,
]
@@ -1209,39 +1217,61 @@ def cmis_browser_type_definition_by_id(
raise KeyError(type_id)
-def cmis_browser_object(projection: dict[str, Any]) -> dict[str, Any]:
- properties = _browser_object_properties(projection)
+def cmis_browser_object(
+ projection: dict[str, Any],
+ *,
+ property_filter: str | None = None,
+ include_allowable_actions: bool = True,
+ include_acl: bool = True,
+) -> dict[str, Any]:
+ unfiltered_properties = _browser_object_properties(projection)
+ properties = _filter_browser_properties(unfiltered_properties, property_filter)
result = {
"properties": {
key: cmis_browser_property_value(key, value)
for key, value in properties.items()
},
"succinctProperties": properties,
- "allowableActions": cmis_browser_allowable_actions(
- projection.get("allowable_actions", []),
- base_type_id=properties.get("cmis:baseTypeId"),
- ),
}
- acl = projection.get("acl")
+ if include_allowable_actions:
+ result["allowableActions"] = cmis_browser_allowable_actions(
+ projection.get("allowable_actions", []),
+ base_type_id=unfiltered_properties.get("cmis:baseTypeId"),
+ )
+ acl = projection.get("acl") if include_acl else None
if isinstance(acl, dict) and acl:
result["acl"] = cmis_browser_acl(acl)
result["exactACL"] = bool(acl.get("is_exact", True))
return compact_dict(result)
-def cmis_browser_object_in_folder_list(children: dict[str, Any]) -> dict[str, Any]:
+def cmis_browser_object_in_folder_list(
+ children: dict[str, Any],
+ *,
+ property_filter: str | None = None,
+ include_allowable_actions: bool = True,
+ include_acl: bool = True,
+ include_path_segment: bool = True,
+) -> dict[str, Any]:
objects = []
for item in children.get("objects", []):
+ entry = {
+ "object": cmis_browser_object(
+ item,
+ property_filter=property_filter,
+ include_allowable_actions=include_allowable_actions,
+ include_acl=include_acl,
+ ),
+ }
+ if include_path_segment:
+ entry["pathSegment"] = item.get("name") or item.get("object_id")
objects.append(
- {
- "object": cmis_browser_object(item),
- "pathSegment": item.get("name") or item.get("object_id"),
- }
+ entry
)
return {
"objects": objects,
"hasMoreItems": bool(children.get("has_more_items", False)),
- "numItems": int(children.get("num_items", len(objects))),
+ "numItems": int(children.get("total_num_items", children.get("num_items", len(objects)))),
}
@@ -1253,15 +1283,17 @@ def cmis_browser_object_list(objects: list[dict[str, Any]], *, has_more_items: b
}
-def cmis_browser_parent_list(parents: dict[str, Any]) -> list[dict[str, Any]]:
+def cmis_browser_parent_list(
+ parents: dict[str, Any],
+ *,
+ include_relative_path_segment: bool = True,
+) -> list[dict[str, Any]]:
items = []
for parent in parents.get("parents", []):
- items.append(
- {
- "object": cmis_browser_object(parent),
- "relativePathSegment": parent.get("name"),
- }
- )
+ item = {"object": cmis_browser_object(parent)}
+ if include_relative_path_segment:
+ item["relativePathSegment"] = parent.get("relative_path_segment") or parent.get("name")
+ items.append(item)
return items
@@ -1270,7 +1302,7 @@ def cmis_browser_query_result(query_result: dict[str, Any]) -> dict[str, Any]:
return {
"results": results,
"hasMoreItems": bool(query_result.get("has_more_items", False)),
- "numItems": int(query_result.get("num_items", len(results))),
+ "numItems": int(query_result.get("total_num_items", query_result.get("num_items", len(results)))),
}
@@ -1351,7 +1383,7 @@ def cmis_browser_allowable_actions(
"canGetFolderTree": False,
"canGetFolderParent": is_folder and CMISAction.GET_OBJECT_PARENTS.value in native,
"canGetRenditions": False,
- "canMoveObject": False,
+ "canMoveObject": CMISAction.MOVE_OBJECT.value in native,
"canAddObjectToFolder": False,
"canRemoveObjectFromFolder": False,
"canCheckOut": False,
@@ -1362,7 +1394,7 @@ def cmis_browser_allowable_actions(
"canRemovePolicy": False,
"canGetAppliedPolicies": False,
"canApplyACL": False,
- "canDeleteContentStream": False,
+ "canDeleteContentStream": CMISAction.SET_CONTENT_STREAM.value in native,
}
@@ -1600,6 +1632,23 @@ def _browser_object_properties(projection: dict[str, Any]) -> dict[str, Any]:
return properties
+def _filter_browser_properties(properties: dict[str, Any], property_filter: str | None) -> dict[str, Any]:
+ if property_filter is None:
+ return properties
+ requested = {
+ part.strip()
+ for part in property_filter.split(",")
+ if part.strip()
+ }
+ if not requested or "*" in requested:
+ return properties
+ return {
+ key: value
+ for key, value in properties.items()
+ if key in requested or any(pattern.endswith(":*") and key.startswith(pattern[:-1]) for pattern in requested)
+ }
+
+
def _browser_property_type(property_id: str, value: Any) -> str:
if property_id in {
"cmis:objectId",
@@ -1677,11 +1726,12 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any
"cmis:lastModificationDate": {"property_type": "datetime", "cardinality": "single", "required": False},
"cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False},
- "cmis:path": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:description": {"property_type": "string", "cardinality": "single", "required": False},
"kontextual:sensitivity": {"property_type": "string", "cardinality": "single", "required": False},
"kontextual:lifecycle": {"property_type": "string", "cardinality": "single", "required": False},
}
+ if base_type_id == CMISBaseType.FOLDER:
+ definitions["cmis:path"] = {"property_type": "string", "cardinality": "single", "required": False}
if base_type_id == CMISBaseType.DOCUMENT:
definitions.update(
{
diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py
index 0f8c7aa..964a3d6 100644
--- a/tests/cmis/test_cmis_browser_binding_api.py
+++ b/tests/cmis/test_cmis_browser_binding_api.py
@@ -142,7 +142,7 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
assert root_object["properties"]["kontextual:workspaceFolder"]["value"] is False
assert "kontextual:assetId" in browser_type_definition["propertyDefinitions"]
assert browser_type_definition["propertyDefinitions"]["kontextual:topics"]["cardinality"] == "multi"
- assert "cmis:path" in browser_type_definition["propertyDefinitions"]
+ assert "cmis:path" not in browser_type_definition["propertyDefinitions"]
assert {item["base_type_id"] for item in types["items"]} >= {
"cmis:document",
"cmis:folder",
@@ -236,11 +236,15 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
)
streamed = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/content",
- json={"content": "# Updated", "media_type": "text/markdown"},
+ json={"content": "# Updated", "media_type": "text/plain; charset=utf-8"},
)
byte_stream = cmis_client.get(
"/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
)
+ byte_range = cmis_client.get(
+ "/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
+ params={"offset": 2, "length": 4},
+ )
deleted = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/delete",
json={},
@@ -248,12 +252,80 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
assert created.status_code == 200
assert updated.json()["properties"]["kontextual:metadata:status"] == "draft"
- assert streamed.json()["content_stream"]["mime_type"] == "text/markdown"
+ assert streamed.json()["content_stream"]["mime_type"] == "text/plain"
assert byte_stream.content == b"# Updated"
+ assert byte_stream.headers["content-type"] == "text/plain"
assert byte_stream.headers["etag"].startswith("sha256:")
+ assert byte_range.content == b"Upda"
+ assert byte_range.headers["content-length"] == "4"
assert deleted.json()["lifecycle"] == "delete_requested"
+def test_cmis_browser_binding_create_document_validates_type_and_secondary_ids(cmis_client) -> None:
+ invalid = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createDocument",
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:folder",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "Invalid Document",
+ },
+ )
+ created = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createDocument",
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:document",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "Secondary Document",
+ "propertyId[2]": "cmis:secondaryObjectTypeIds",
+ "propertyValue[2][0]": "kontextual:secondary",
+ },
+ files={"content": ("secondary.txt", b"Secondary content", "text/plain")},
+ )
+
+ assert invalid.status_code == 422
+ assert invalid.json()["detail"]["details"]["type_id"] == "cmis:folder"
+ assert created.status_code == 200
+ assert created.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == ["kontextual:secondary"]
+
+
+def test_cmis_browser_binding_document_without_content_streams_empty_compatibility_body(cmis_client) -> None:
+ folder = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createFolder",
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:folder",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "No Content Folder",
+ },
+ ).json()
+ document = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createDocument",
+ "objectId": folder["properties"]["cmis:objectId"]["value"],
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:document",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "No Content Document",
+ },
+ ).json()
+ object_id = document["properties"]["cmis:objectId"]["value"]
+ content = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "content", "objectId": object_id},
+ )
+
+ assert document["properties"]["cmis:contentStreamMimeType"]["value"] is None
+ assert content.status_code == 200
+ assert content.content == b""
+ assert content.headers["content-length"] == "0"
+
+
def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None:
created = cmis_client.post(
"/cmis/compat-tck/browser/root",
@@ -291,14 +363,77 @@ def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis
},
files={"content": ("multipart.txt", b"Multipart content", "text/plain")},
)
- document_path = document.json()["properties"]["cmis:path"]["value"]
+ document_id = document.json()["properties"]["cmis:objectId"]["value"]
+ document_path = "/Action Workspace/Multipart Document"
fetched_document_by_path = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={"cmisselector": "object", "path": document_path},
).json()
+ fetched_document_by_url_path = cmis_client.get(
+ "/cmis/compat-tck/browser/root/Action%20Workspace/Multipart%20Document",
+ params={"cmisselector": "object"},
+ ).json()
+ updated_document_by_alias = cmis_client.post(
+ "/cmis/compat-tck/browser/root/Action%20Workspace/Multipart%20Document",
+ data={
+ "cmisaction": "update",
+ "propertyId[0]": "cmis:secondaryObjectTypeIds",
+ "propertyValue[0][0]": "kontextual:secondary",
+ },
+ )
+ destination = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createFolder",
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:folder",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "Destination",
+ },
+ )
+ destination_id = destination.json()["properties"]["cmis:objectId"]["value"]
+ moved_document = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "move",
+ "objectId": document_id,
+ "sourceFolderId": folder_id,
+ "targetFolderId": destination_id,
+ },
+ )
+ moved_path = "/Destination/Multipart Document"
+ fetched_old_document_path_after_move = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "object", "path": document_path},
+ )
+ fetched_moved_document_by_path = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "object", "path": moved_path},
+ ).json()
document_parents = cmis_client.get(
"/cmis/compat-tck/browser/root",
- params={"cmisselector": "parents", "objectId": document.json()["properties"]["cmis:objectId"]["value"]},
+ params={"cmisselector": "parents", "objectId": document_id},
+ ).json()
+ filtered_document = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={
+ "cmisselector": "object",
+ "path": moved_path,
+ "filter": "cmis:objectId,cmis:name",
+ "includeAllowableActions": False,
+ "includeACL": False,
+ },
+ ).json()
+ filtered_children = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={
+ "cmisselector": "children",
+ "objectId": destination_id,
+ "filter": "cmis:objectId,cmis:name",
+ "includeAllowableActions": False,
+ "includeACL": False,
+ "includePathSegment": False,
+ },
).json()
fetched_folder_by_path = cmis_client.get(
"/cmis/compat-tck/browser/root",
@@ -334,9 +469,26 @@ def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis
assert document.json()["properties"]["cmis:isLatestVersion"]["value"] is True
assert document.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == []
assert document.json()["allowableActions"]["canGetFolderParent"] is False
+ assert document.json()["allowableActions"]["canMoveObject"] is True
assert document_path == "/Action Workspace/Multipart Document"
assert fetched_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document"
- assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Action Workspace"
+ assert fetched_document_by_url_path["properties"]["cmis:name"]["value"] == "Multipart Document"
+ assert updated_document_by_alias.status_code == 200
+ assert updated_document_by_alias.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == [
+ "kontextual:secondary"
+ ]
+ assert destination.status_code == 200
+ assert moved_document.status_code == 200
+ assert moved_path == "/Destination/Multipart Document"
+ assert fetched_old_document_path_after_move.status_code == 404
+ assert fetched_moved_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document"
+ assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Destination"
+ assert document_parents[0]["relativePathSegment"] == "Multipart Document"
+ assert set(filtered_document["properties"]) == {"cmis:objectId", "cmis:name"}
+ assert "allowableActions" not in filtered_document
+ assert "pathSegment" not in filtered_children["objects"][0]
+ assert set(filtered_children["objects"][0]["object"]["properties"]) == {"cmis:objectId", "cmis:name"}
+ assert "allowableActions" not in filtered_children["objects"][0]["object"]
assert fetched_folder_by_path["properties"]["cmis:objectId"]["value"] == folder_id
assert deleted_tree.json()["failedToDelete"] == []
assert all(
@@ -368,9 +520,14 @@ def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None:
def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis_client) -> None:
response = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-source/properties",
- json={"properties": {"cmis:name": "Renamed"}},
+ json={"properties": {"cmis:objectTypeId": "cmis:folder"}},
)
assert response.status_code == 422
- assert response.json()["detail"]["details"]["property"] == "cmis:name"
- assert response.json()["detail"]["details"]["supported"] == ["kontextual:metadata:"]
+ assert response.json()["detail"]["details"]["property"] == "cmis:objectTypeId"
+ assert response.json()["detail"]["details"]["supported"] == [
+ "cmis:name",
+ "cmis:description",
+ "cmis:secondaryObjectTypeIds",
+ "kontextual:metadata:",
+ ]
diff --git a/tests/cmis/test_cmis_runtime_browser_binding.py b/tests/cmis/test_cmis_runtime_browser_binding.py
index 6991d27..837a8d1 100644
--- a/tests/cmis/test_cmis_runtime_browser_binding.py
+++ b/tests/cmis/test_cmis_runtime_browser_binding.py
@@ -215,6 +215,24 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru
folder_children = runtime.cmis_children("compat-tck", context, folder_id=folder_object_id)
document_by_path = runtime.cmis_object_by_path("compat-tck", "/TCK Workspace/Workspace Document", context)
document_parents = runtime.cmis_object_parents("compat-tck", document["object_id"], context)
+ browser_document_parents = runtime.cmis_browser_parents("compat-tck", document["object_id"], context)
+ filtered_document = runtime.cmis_browser_object(
+ "compat-tck",
+ document["object_id"],
+ context,
+ property_filter="cmis:objectId,cmis:name",
+ include_allowable_actions=False,
+ include_acl=False,
+ )
+ filtered_children = runtime.cmis_browser_children(
+ "compat-tck",
+ context,
+ object_id=folder_object_id,
+ property_filter="cmis:objectId,cmis:name",
+ include_allowable_actions=False,
+ include_acl=False,
+ include_path_segment=False,
+ )
assert folder["path"] == "/TCK Workspace"
assert folder["properties"]["kontextual:workspaceFolder"] is True
@@ -222,11 +240,17 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru
assert fetched["properties"]["cmis:path"] == "/TCK Workspace"
assert parents["parents"][0]["object_id"] == "cmis-root"
assert document["path"] == "/TCK Workspace/Workspace Document"
- assert document["properties"]["cmis:path"] == "/TCK Workspace/Workspace Document"
+ assert "cmis:path" not in document["properties"]
assert document_by_path["object_id"] == document["object_id"]
assert document_parents["count"] == 1
assert document_parents["parents"][0]["properties"]["cmis:path"] == "/TCK Workspace"
+ assert browser_document_parents[0]["relativePathSegment"] == "Workspace Document"
assert document["object_id"] in {item["object_id"] for item in folder_children["objects"]}
+ assert set(filtered_document["properties"]) == {"cmis:objectId", "cmis:name"}
+ assert "allowableActions" not in filtered_document
+ assert "pathSegment" not in filtered_children["objects"][0]
+ assert set(filtered_children["objects"][0]["object"]["properties"]) == {"cmis:objectId", "cmis:name"}
+ assert "allowableActions" not in filtered_children["objects"][0]["object"]
with pytest.raises(Exception) as exc_info:
runtime.cmis_delete_object("compat-tck", folder_object_id, {}, context)
@@ -242,6 +266,31 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru
assert "CMIS folder not found" in str(exc_info.value)
+def test_runtime_cmis_workspace_folder_rename_keeps_object_id_stable(cmis_runtime) -> None:
+ runtime, context = cmis_runtime
+
+ folder = runtime.cmis_create_folder(
+ "compat-tck",
+ {"name": "Rename Source", "properties": {"cmis:objectTypeId": "cmis:folder"}},
+ context,
+ )
+ renamed = runtime.cmis_update_properties(
+ "compat-tck",
+ folder["object_id"],
+ {"properties": {"cmis:name": "Rename Target"}},
+ context,
+ )
+ fetched_by_old_id = runtime.cmis_object("compat-tck", folder["object_id"], context)
+ fetched_by_new_path = runtime.cmis_object_by_path("compat-tck", "/Rename Target", context)
+ deleted = runtime.cmis_delete_object("compat-tck", folder["object_id"], {}, context)
+
+ assert renamed["object_id"] == folder["object_id"]
+ assert renamed["path"] == "/Rename Target"
+ assert fetched_by_old_id["properties"]["cmis:name"] == "Rename Target"
+ assert fetched_by_new_path["object_id"] == folder["object_id"]
+ assert deleted["deleted"] is True
+
+
def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime) -> None:
runtime, context = cmis_runtime
@@ -249,7 +298,7 @@ def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime
runtime.cmis_update_properties(
"governed-authoring",
"cmis:asset:asset-runtime-source",
- {"properties": {"cmis:name": "Renamed"}},
+ {"properties": {"cmis:objectTypeId": "cmis:folder"}},
context,
)
diff --git a/workplans/KONT-WP-0014-cmis-object-content-maturity.md b/workplans/KONT-WP-0014-cmis-object-content-maturity.md
index 964500d..5ea229d 100644
--- a/workplans/KONT-WP-0014-cmis-object-content-maturity.md
+++ b/workplans/KONT-WP-0014-cmis-object-content-maturity.md
@@ -127,6 +127,75 @@ Current external frontier:
These are follow-up maturity items rather than the original folder-creatable
blocker.
+## Implementation Evidence - 2026-05-08T15:33:16Z
+
+Evidence file:
+
+- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md`
+
+Implemented in this pass:
+
+- Browser Binding action aliases and path-addressed routes:
+ `cmisaction=update`, `cmisaction=move`, and `/browser/root/{path}`.
+- MIME normalization and explicit content stream `Content-Type` headers.
+- Metadata-backed standard property support for `cmis:name`,
+ `cmis:description`, and `cmis:secondaryObjectTypeIds`.
+- Create-time secondary type id projection and invalid document type rejection.
+- Removal of non-standard document `cmis:path` while preserving folder paths.
+
+Latest verification:
+
+- Internal: `.venv/bin/python -m pytest tests/cmis -q` -> `48 passed`.
+- OpenCMIS: `run-20260508T153316Z` in
+ `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z`.
+- Maturity score: `19.05`; coverage remains `2/9` groups.
+- `repository-type` is now partial/warning; `object-content` remains
+ infrastructure-blocked by concrete CRUD/content edge cases.
+
+Current external frontier:
+
+- `getObjectByPath` path-segment failures in child checks.
+- No-content document content-stream semantics.
+- Operation-context/property filter trimming, especially folder `cmis:path`.
+- `bulkUpdate`, `deleteContent`, and change-token unsupported-action handling.
+
+## Implementation Evidence - 2026-05-08T16:43:34Z
+
+Evidence file:
+
+- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md`
+
+Implemented in this pass:
+
+- Correct `relativePathSegment` behavior for document parents.
+- Browser Binding operation-context trimming for property filters, allowable
+ actions, ACLs, and path segments.
+- Total-count `numItems` semantics for Browser Binding children.
+- Stable adapter-managed folder object IDs across folder rename/update.
+- Nullable no-content document stream properties and empty compatibility streams.
+- Range-aware content responses with sliced bodies, `206`, and `Content-Range`.
+- `setContent` and `deleteContent` Browser Binding action aliases.
+
+Latest verification:
+
+- Internal focused CMIS tests: `20 passed`.
+- Full suite: `160 passed, 14 skipped`.
+- OpenCMIS: `run-20260508T164334Z` in
+ `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z`.
+- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`.
+- `object-content`: `12 info`, `5 skipped`, `3 warning`, `3 fail`,
+ `3 infrastructure_error`.
+
+Current external frontier:
+
+- CMIS-specific exception mapping for invalid type operations.
+- `bulkUpdateProperties` remains unsupported.
+- `deleteContentStream` needs stronger representation-removal/tombstone
+ semantics.
+- Change-token conflict behavior is not implemented.
+- `createDocumentFromSource`/copy remains unsupported.
+- Offset-zero range requests are still marked partial.
+
## D14.1 - Define CMIS maturity boundary and TCK profile semantics
```task
@@ -187,7 +256,7 @@ Acceptance:
```task
id: KONT-WP-0014-T004
-status: in_progress
+status: done
priority: high
state_hub_task_id: "f9323c25-4d81-42cd-b7e6-e40d7e0487cd"
```
@@ -208,7 +277,7 @@ Acceptance:
```task
id: KONT-WP-0014-T005
-status: todo
+status: in_progress
priority: medium
state_hub_task_id: "5feb6db8-24eb-4c20-8c3e-d530f396ef6a"
```
@@ -223,6 +292,13 @@ Acceptance:
naturally through blob services or explicitly advertised as unsupported.
- Blob deduplication and digest verification remain intact.
+Progress:
+
+- Done for normal reads, no-content compatibility streams, partial body slicing,
+ `Content-Length`, `Content-Type`, `ETag`, and `Content-Range`.
+- Remaining: clean `deleteContentStream` semantics and offset-zero range
+ classification.
+
## D14.6 - Add natural navigation and query depth
```task
@@ -242,11 +318,18 @@ Acceptance:
indexed/available metadata fields, or returns precise unsupported diagnostics.
- Capability flags are updated only for behavior that is actually supported.
+Progress:
+
+- Done for `getObjectByPath`, `getFolderParent`, parent path segments, and
+ folder rename path stability.
+- Remaining: query predicate/order depth and any deliberate descendants/tree
+ expansion.
+
## D14.7 - Polish read-side relationships, ACL discovery, and change tokens
```task
id: KONT-WP-0014-T007
-status: todo
+status: in_progress
priority: medium
state_hub_task_id: "60f7b222-6eea-4add-822d-3439d568d4f6"
```
@@ -261,6 +344,11 @@ Acceptance:
- ACL mutation, policy mutation, PWC/versioning, and type mutability remain
unsupported unless a later task explicitly changes scope.
+Progress:
+
+- Started by isolating OpenCMIS change-token failures as the main T007 maturity
+ gap. Relationship and ACL discovery were not expanded in this pass.
+
## D14.8 - Expand OpenCMIS assessment and update maturity scorecard
```task