diff --git a/docs/cmis-1-1-capability-scorecard.md b/docs/cmis-1-1-capability-scorecard.md
index 0ed8e6d..e9b782b 100644
--- a/docs/cmis-1-1-capability-scorecard.md
+++ b/docs/cmis-1-1-capability-scorecard.md
@@ -1,20 +1,18 @@
# CMIS 1.1 Capability Scorecard
-Date: 2026-05-07
+Date: 2026-05-13
-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`.
+Evidence update: the 2026-05-13 WP-0014 OpenCMIS pass completed the selected
+Browser Binding `repository-type` and `object-content` baseline. The latest
+run, `run-20260513T210255Z`, reports `0` unexpected findings:
+`repository-type` is warning-only because the local harness uses HTTP, and
+`object-content` is warning-only because `appendContentStream()` is not
+supported. See
+`docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md`.
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.
+The selected OpenCMIS baseline is now stable preparation evidence for
+repository/type and object/content services, not a full CMIS certification.
Status: baseline scorecard for the current Browser Binding subset.
@@ -74,58 +72,56 @@ CMIS interoperability importance rather than engine-internal importance.
| Metric | Score |
| --- | ---: |
-| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 51% |
-| Controlled-client Browser Binding usefulness | 70% |
-| Broad commodity CMIS client compatibility | 46% |
+| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 59% |
+| Controlled-client Browser Binding usefulness | 80% |
+| Broad commodity CMIS client compatibility | 54% |
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
-replacement surface.
+known clients and profile-specific integrations, especially around repository,
+type, object, folder, content, move/copy, and controlled mutation workflows. It
+is not yet a broad ECM/CMIS replacement surface.
## Capability Scorecard
| CMIS capability area | Weight | Current depth | Most worthy contender | Gap basis behind the percentage |
| --- | ---: | ---: | --- | --- |
-| 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. |
+| Repository service and repository info | 5 | 86% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.
- Unsupported feature catalog exists.
- OpenCMIS `repository-type` completes with only the local HTTP warning. |
+| Type definitions | 6 | 55% | Hyland Alfresco ACS | - Base types, Browser Binding type definitions, secondary type projection, and nullable content stream properties exist.
- No mutable types or custom schema/type management.
- Property definition depth remains intentionally narrow. |
+| Navigation service | 8 | 62% | Hyland Alfresco ACS | - Root and folder-scoped children, path lookup, folder parent lookup, parent path segments, move, and delete-tree work in the selected baseline.
- Projection-only parents exist.
- Missing `getDescendants`, `getFolderTree`, and real filing mutations. |
+| Object read service | 10 | 84% | 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 correctly not exposed.
- OpenCMIS object/content read-side baseline now completes with warnings only. |
+| Object write service | 8 | 70% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set/delete, `bulkUpdateProperties`, and `createDocumentFromSource` exist.
- No broad filing mutation, raw physical delete, checkout/checkin, or policy/item creation.
- Delete remains intentionally governed, not raw repository removal. |
+| Content stream read/write | 8 | 82% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, no-content compatibility streams, content tombstones, partial body slicing, and offset-zero full-stream classification exist.
- Digest verification and governed access exist.
- Remaining warning is unsupported append semantics. |
| 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 CMIS update-conflict behavior for reused change tokens and richer change event typing.
- Change-token maturity is now directly visible in OpenCMIS object/content. |
+| Change log | 5 | 60% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.
- CMIS change-token conflicts are now enforced for property/content mutations.
- Missing richer change event typing and broader token semantics across optional services. |
| 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 | 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. |
+| Bulk update | 2 | 65% | Hyland Alfresco ACS | - `bulkUpdateProperties` works for the `compat-tck` profile by batching existing property updates with change-token handling.
- It is intentionally narrow and not enabled on the normal governed-authoring profile yet.
- No advanced partial-success envelope beyond the Browser Binding response list. |
+| Browser Binding protocol fidelity | 7 | 78% | Hyland Alfresco ACS | - Browser-style routes, JSON envelopes, CMIS exception names, action aliases, multipart forms, path-addressed root routes, property filters, path segments, and range responses exist.
- Selected OpenCMIS Browser Binding repository/type and object/content baseline completes with warnings only.
- Optional services and broader CMIS SQL/versioning protocol surfaces remain incomplete. |
| 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 | 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. |
+| External conformance evidence | 3 | 82% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.
- Selected `repository-type` and `object-content` baselines complete with warnings only.
- Evidence still covers a selected baseline, not the full OpenCMIS TCK surface. |
-Weighted result from this table: **51%**.
+Weighted result from this table: **59%**.
## Most Important Gaps
1. **External conformance expansion**
- Keep the selected OpenCMIS TCK baseline running against `compat-tck`.
- - 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.
+ - Treat the current repository/type and object/content warnings as known:
+ local HTTP and unsupported append.
+ - Expand selected groups after the supported baseline remains 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.
+ - Continue returning CMIS-specific exception classes/statuses as more
+ optional services are added.
3. **Query depth**
- Add a real CMIS SQL subset parser instead of a two-query allowlist.
@@ -146,6 +142,11 @@ Weighted result from this table: **51%**.
- Map selected derived representations to CMIS renditions only after we have
stable representation taxonomy and real preview/thumbnail use cases.
+7. **Append streams**
+ - Keep `appendContentStream()` unsupported unless a real client requires it.
+ - If implemented later, design it through blob composition/deduplication
+ rather than byte concatenation in the CMIS adapter.
+
## Product Positioning Takeaway
Against a mature CMIS implementation such as Hyland Alfresco ACS, Kontextual is
diff --git a/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md
new file mode 100644
index 0000000..f9a095f
--- /dev/null
+++ b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md
@@ -0,0 +1,75 @@
+# CMIS OpenCMIS TCK Evidence - WP-0014 - 2026-05-13T21:02:55Z
+
+## Run Summary
+
+- Run ID: `run-20260513T210255Z`
+- Harness: `guide-board` with `open-cmis-tck` extension
+- Assessment: `cmis-browser-baseline`
+- Target: `kontextual-cmis-compat`
+- Endpoint: `http://127.0.0.1:8010/cmis/compat-tck/browser`
+- OpenCMIS TCK: CMIS 1.1.0, revision `1789681`
+- Result: `completed`
+- Policy: `0` unexpected findings, `0` applied waivers
+- Internal verification: `.venv/bin/python -m pytest -q` -> `165 passed`,
+ `14 skipped`
+
+The previous unpersisted `/tmp` assessment output was not available anymore,
+so this run is the persisted evidence baseline for the remaining WP-0014 CMIS
+object/content maturity blockers.
+
+## Normalized Results
+
+| Group | Result | Counts | Remaining non-green findings |
+| --- | --- | --- | --- |
+| `repository-type` | `warning` | `38 pass`, `2 info`, `1 skipped`, `1 warning` | Local test endpoint uses HTTP rather than HTTPS. |
+| `object-content` | `warning` | `10 info`, `5 skipped`, `1 warning` | `appendContentStream()` is not supported. |
+
+Guide Board summary:
+
+- `pass`: 1
+- `warning`: 2
+
+## Blocker Resolution
+
+Resolved in this pass:
+
+- CMIS-specific exception mapping now returns Browser Binding exception names
+ such as `invalidArgument`, `constraint`, `updateConflict`, `notSupported`,
+ `objectNotFound`, and `permissionDenied` instead of leaking generic `422`
+ diagnostics into CMIS clients.
+- `bulkUpdateProperties` is implemented for the `compat-tck` Browser Binding
+ profile by applying the existing CMIS `updateProperties` path to each
+ requested object with per-object change-token handling.
+- `deleteContentStream` now records a content-change version, tombstones the
+ content projection, clears content-stream properties, and returns CMIS
+ `constraint` for subsequent stream reads so OpenCMIS resolves the stream to
+ `null` rather than treating the document as deleted.
+- Change-token conflicts are enforced for CMIS property and content mutations.
+- `createDocumentFromSource`/copy is implemented by creating a governed asset
+ copy that reuses representation blob references instead of duplicating bytes.
+- Offset-zero content range requests are classified as full-stream responses
+ unless a non-zero offset or explicit length is requested.
+
+## Remaining Scope
+
+Remaining warning-only items:
+
+- `appendContentStream()` is unsupported by design for now. The engine supports
+ whole-stream replacement through deduplicated content services; append would
+ need a deliberate blob-composition design before being advertised.
+- HTTPS is not used in the local harness endpoint. This is deployment/test
+ topology, not a CMIS adapter behavior gap.
+
+Skipped OpenCMIS object/content cases are aligned with declared capability
+boundaries:
+
+- Relationship, policy, and item creation are not advertised as creatable.
+- Folder-name change-token subcases are skipped by the TCK.
+
+## Interpretation
+
+The selected Browser Binding baseline is now stable enough to be considered a
+completed compatibility-preparation baseline for repository/type and
+object/content services. This is not full CMIS 1.1 certification and does not
+cover AtomPub, Web Services, versioning/PWC, full query, renditions, retention,
+or policy mutation depth.
diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py
index 95bf881..eb2ab63 100644
--- a/src/kontextual_engine/api/app.py
+++ b/src/kontextual_engine/api/app.py
@@ -20,6 +20,7 @@ from kontextual_engine.core import (
Actor,
ActorType,
AssetRepresentation,
+ AssetVersion,
AuditEvent,
AuditOutcome,
Classification,
@@ -42,6 +43,7 @@ from kontextual_engine.core import (
RetrievalFeedbackLabel,
SourceReference,
TransformationRunStatus,
+ VersionChangeType,
WorkflowExceptionKind,
WorkflowExceptionStatus,
WorkflowInputDefinition,
@@ -734,7 +736,17 @@ 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):
+ if asset.metadata.get("cmis_content_deleted"):
+ raise ValidationError(
+ "CMIS document has no content stream",
+ details={
+ "code": "cmis.no_content_stream",
+ "cmis_exception": "constraint",
+ "object_id": object_id,
+ "access_point_id": access_point_id,
+ },
+ )
+ if not self._cmis_asset_representations(asset):
digest = content_digest(b"")
representation = AssetRepresentation(
asset_id=asset_id,
@@ -984,6 +996,206 @@ class ServiceRuntime:
payload["folder_id"] = parent_folder_id
return cmis_browser_object(self.cmis_create_document(access_point_id, payload, context))
+ def cmis_create_document_from_source(
+ self,
+ access_point_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.CREATE_DOCUMENT, context)
+ if not decision.allowed:
+ raise _cmis_authorization_error(decision, "createDocumentFromSource")
+ properties = _cmis_browser_properties(payload)
+ type_id = properties.get("cmis:objectTypeId")
+ if type_id and type_id not in {CMISBaseType.DOCUMENT.value, "kontextual:document"}:
+ raise ValidationError(
+ "Invalid CMIS source-copy document type",
+ details={"operation": "createDocumentFromSource", "type_id": type_id},
+ )
+ source_object_id = payload.get("sourceId") or payload.get("source_id") or payload.get("sourceObjectId")
+ if not source_object_id:
+ raise ValidationError(
+ "CMIS source object id is required",
+ details={"operation": "createDocumentFromSource", "parameter": "sourceId"},
+ )
+ source_asset_id = _cmis_asset_id(str(source_object_id))
+ source_asset = self.repository.get_asset(source_asset_id)
+ if not mapper.access_point.exposes_asset(source_asset, context):
+ raise NotFoundError(
+ "CMIS source object not found",
+ details={"object_id": source_object_id, "access_point_id": access_point_id},
+ )
+ name = str(properties.get("cmis:name") or payload.get("name") or source_asset.title).strip()
+ if not name:
+ raise ValidationError("CMIS name cannot be empty", details={"operation": "createDocumentFromSource"})
+ asset_id = payload.get("asset_id") or new_id("asset")
+ representations = [
+ replace(
+ representation,
+ asset_id=asset_id,
+ representation_id=new_id("repr"),
+ producer="cmis-createDocumentFromSource",
+ metadata={
+ **representation.metadata,
+ "cmis_source_object_id": str(source_object_id),
+ "cmis_source_representation_id": representation.representation_id,
+ },
+ )
+ for representation in self._cmis_asset_representations(source_asset)
+ ]
+ result = self.asset_service().create_asset(
+ name,
+ source_asset.classification,
+ context,
+ asset_id=asset_id,
+ source_refs=list(source_asset.source_refs),
+ representations=representations,
+ idempotency_key=payload.get("idempotency_key"),
+ )
+ metadata = {
+ key: value
+ for key, value in source_asset.metadata.items()
+ if key not in {"cmis_path", "cmis_paths", "cmis_parent_folder_id", "cmis_content_deleted"}
+ }
+ metadata.update(
+ {
+ "cmis_copied_from_object_id": str(source_object_id),
+ "cmis_copied_from_asset_id": source_asset.id,
+ "file_name": name,
+ }
+ )
+ 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 "/"
+ metadata["cmis_path"] = _normalize_cmis_path(f"{folder_path}/{name}")
+ metadata["cmis_parent_folder_id"] = "cmis-root" if folder_path == "/" else mapper.folder_object_id(folder_path)
+ self.repository.save_asset(replace(result.asset, metadata=metadata))
+ return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context)
+
+ def cmis_browser_create_document_from_source(
+ self,
+ access_point_id: str,
+ payload: dict[str, Any],
+ context: OperationContext,
+ *,
+ parent_folder_id: str | None = None,
+ ) -> dict[str, Any]:
+ payload = dict(payload)
+ if parent_folder_id and "folder_id" not in payload and "folderId" not in payload:
+ payload["folder_id"] = parent_folder_id
+ return cmis_browser_object(self.cmis_create_document_from_source(access_point_id, payload, context))
+
+ def cmis_bulk_update_properties(
+ self,
+ access_point_id: str,
+ payload: dict[str, Any],
+ context: OperationContext,
+ ) -> list[dict[str, Any]]:
+ mapper = self._cmis_mapper(access_point_id)
+ decision = mapper.access_point.decide_action(CMISAction.BULK_UPDATE_PROPERTIES, context)
+ if not decision.allowed:
+ raise _cmis_authorization_error(decision, "bulkUpdateProperties")
+ entries = _cmis_browser_bulk_entries(payload)
+ if not entries:
+ raise ValidationError(
+ "CMIS bulk update object ids are required",
+ details={"operation": "bulkUpdateProperties", "parameter": "objectId"},
+ )
+ properties = _cmis_browser_properties(payload)
+ if not properties:
+ raise ValidationError(
+ "CMIS bulk update properties are required",
+ details={"operation": "bulkUpdateProperties", "parameter": "propertyId"},
+ )
+ updated_entries: list[dict[str, Any]] = []
+ for entry in entries:
+ update_payload: dict[str, Any] = {"properties": dict(properties)}
+ if entry.get("change_token"):
+ update_payload["expected_current_version_id"] = entry["change_token"]
+ updated = self.cmis_update_properties(access_point_id, entry["object_id"], update_payload, context)
+ updated_properties = updated.get("properties", {})
+ object_id_property = updated_properties.get("cmis:objectId")
+ change_token_property = updated_properties.get("cmis:changeToken")
+ updated_entries.append(
+ {
+ "id": entry["object_id"],
+ "newId": object_id_property,
+ "changeToken": change_token_property,
+ }
+ )
+ return updated_entries
+
+ def _cmis_expected_change_token(self, payload: dict[str, Any], properties: dict[str, Any]) -> str | None:
+ return properties.pop(
+ "expected_current_version_id",
+ payload.get("expected_current_version_id") or payload.get("changeToken") or payload.get("change_token"),
+ )
+
+ def _cmis_assert_change_token(
+ self,
+ asset,
+ expected_current_version_id: str | None,
+ *,
+ operation: str,
+ ) -> None:
+ if not expected_current_version_id:
+ return
+ if asset.current_version_id != expected_current_version_id:
+ raise ValidationError(
+ "CMIS change token conflict",
+ details={
+ "code": "asset.version_conflict",
+ "cmis_exception": "updateConflict",
+ "operation": operation,
+ "asset_id": asset.id,
+ "expected_current_version_id": expected_current_version_id,
+ "current_version_id": asset.current_version_id,
+ },
+ )
+
+ def _cmis_record_asset_version(
+ self,
+ asset,
+ context: OperationContext,
+ *,
+ change_type: VersionChangeType,
+ operation_id: str,
+ metadata_delta: dict[str, Any] | None = None,
+ representation_ids: tuple[str, ...] = (),
+ ):
+ versions = self.repository.list_versions(asset.id)
+ next_sequence = max((version.sequence for version in versions), default=0) + 1
+ version = AssetVersion(
+ asset_id=asset.id,
+ sequence=next_sequence,
+ change_type=change_type,
+ representation_ids=representation_ids,
+ actor_id=context.actor.id,
+ operation_id=operation_id,
+ parent_version_id=asset.current_version_id,
+ metadata_delta=dict(metadata_delta or {}),
+ lifecycle=asset.lifecycle.value,
+ )
+ updated = asset.with_current_version(version.version_id)
+ self.repository.save_actor(context.actor)
+ self.repository.save_asset(updated)
+ self.repository.save_version(version)
+ self.repository.save_audit_event(
+ AuditEvent.from_context(
+ operation_id,
+ f"asset:{asset.id}",
+ AuditOutcome.SUCCESS,
+ context,
+ details={"version_id": version.version_id},
+ )
+ )
+ return updated
+
def cmis_update_properties(
self,
access_point_id: str,
@@ -996,7 +1208,7 @@ class ServiceRuntime:
if not decision.allowed:
raise _cmis_authorization_error(decision, "updateProperties")
properties = dict(payload.get("properties", payload))
- expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None))
+ expected = self._cmis_expected_change_token(payload, properties)
if object_id.startswith("cmis:folder:"):
return self._cmis_update_workspace_folder(mapper, object_id, properties, context)
asset_id = _cmis_asset_id(object_id)
@@ -1038,6 +1250,17 @@ class ServiceRuntime:
expected = None
if asset_metadata_updates or title_update is not None:
asset = self.repository.get_asset(asset_id)
+ self._cmis_assert_change_token(asset, expected, operation="updateProperties")
+ metadata_delta = dict(asset_metadata_updates)
+ if title_update is not None:
+ metadata_delta["title"] = title_update
+ asset = self._cmis_record_asset_version(
+ asset,
+ context,
+ change_type=VersionChangeType.METADATA_CHANGED,
+ operation_id="cmis.updateProperties",
+ metadata_delta=metadata_delta,
+ )
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"]))
@@ -1061,6 +1284,11 @@ class ServiceRuntime:
if not decision.allowed:
raise _cmis_authorization_error(decision, "setContentStream")
asset_id = _cmis_asset_id(object_id)
+ expected = (
+ payload.get("expected_current_version_id")
+ or payload.get("changeToken")
+ or payload.get("change_token")
+ )
asset = self.repository.get_asset(asset_id)
if asset.metadata.get("cmis_content_deleted"):
metadata = dict(asset.metadata)
@@ -1072,7 +1300,7 @@ class ServiceRuntime:
_cmis_media_type(payload.get("media_type", "text/plain")),
payload.get("content", ""),
context,
- expected_current_version_id=payload.get("expected_current_version_id"),
+ expected_current_version_id=expected,
metadata={"cmis": {"operation": "setContentStream"}},
)
return self.cmis_object(access_point_id, object_id, context)
@@ -1082,11 +1310,13 @@ class ServiceRuntime:
access_point_id: str,
object_id: str,
context: OperationContext,
+ payload: dict[str, Any] | None = None,
) -> 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")
+ payload = payload or {}
asset_id = _cmis_asset_id(object_id)
asset = self.repository.get_asset(asset_id)
if not mapper.access_point.exposes_asset(asset, context):
@@ -1094,6 +1324,20 @@ class ServiceRuntime:
"CMIS object not found",
details={"object_id": object_id, "access_point_id": access_point_id},
)
+ expected = (
+ payload.get("expected_current_version_id")
+ or payload.get("changeToken")
+ or payload.get("change_token")
+ )
+ self._cmis_assert_change_token(asset, expected, operation="deleteContentStream")
+ asset = self._cmis_record_asset_version(
+ asset,
+ context,
+ change_type=VersionChangeType.CONTENT_CHANGED,
+ operation_id="cmis.deleteContentStream",
+ metadata_delta={"cmis_content_deleted": True},
+ representation_ids=tuple(representation.representation_id for representation in self._cmis_asset_representations(asset)),
+ )
self.repository.save_asset(replace(asset, metadata={**asset.metadata, "cmis_content_deleted": True}))
return self.cmis_object(access_point_id, object_id, context)
@@ -3067,22 +3311,86 @@ def create_app(runtime: ServiceRuntime | None = None):
)
app.state.kontextual_runtime = runtime
+ def _is_cmis_request(request: Request) -> bool:
+ return str(request.url.path).startswith("/cmis/")
+
+ def _cmis_error_response(status_code: int, payload: dict[str, Any]) -> JSONResponse:
+ details = dict(payload.get("details", {})) if isinstance(payload.get("details"), dict) else {}
+ message = str(payload.get("message") or payload.get("detail") or "CMIS request failed")
+ code = str(details.get("code") or payload.get("code") or "kontextual.cmis")
+ cmis_exception = details.get("cmis_exception")
+ resolved_status = status_code
+ if not cmis_exception:
+ lowered = message.lower()
+ if code == "kontextual.not_found" or status_code == 404:
+ cmis_exception = "objectNotFound"
+ resolved_status = 404
+ elif code == "kontextual.authorization" or status_code == 403:
+ cmis_exception = "permissionDenied"
+ resolved_status = 403
+ elif code == "asset.version_conflict":
+ cmis_exception = "updateConflict"
+ resolved_status = 409
+ elif code == "cmis.not_supported" or lowered.startswith("unsupported cmis browser binding action"):
+ cmis_exception = "notSupported"
+ resolved_status = 405
+ elif "path already exists" in lowered or "cannot be moved" in lowered:
+ cmis_exception = "constraint"
+ resolved_status = 409
+ else:
+ cmis_exception = "invalidArgument"
+ resolved_status = 400
+ elif cmis_exception == "objectNotFound":
+ resolved_status = 404
+ elif cmis_exception == "permissionDenied":
+ resolved_status = 403
+ elif cmis_exception == "updateConflict":
+ resolved_status = 409
+ elif cmis_exception == "notSupported":
+ resolved_status = 405
+ elif cmis_exception == "constraint":
+ resolved_status = 409
+ elif status_code == 422:
+ resolved_status = 400
+ content = {
+ "exception": cmis_exception,
+ "message": message,
+ "code": code,
+ "details": details,
+ }
+ return JSONResponse(status_code=resolved_status, content=content)
+
@app.exception_handler(NotFoundError)
async def not_found_error_handler(_request, exc: NotFoundError) -> JSONResponse:
+ if _is_cmis_request(_request):
+ return _cmis_error_response(404, _error_payload(exc))
return JSONResponse(status_code=404, content=_error_payload(exc))
@app.exception_handler(AuthorizationError)
async def authorization_error_handler(_request, exc: AuthorizationError) -> JSONResponse:
+ if _is_cmis_request(_request):
+ return _cmis_error_response(403, _authorization_error_payload(exc))
return JSONResponse(status_code=403, content=_authorization_error_payload(exc))
@app.exception_handler(ValidationError)
async def validation_error_handler(_request, exc: ValidationError) -> JSONResponse:
+ if _is_cmis_request(_request):
+ return _cmis_error_response(422, _error_payload(exc))
return JSONResponse(status_code=422, content=_error_payload(exc))
@app.exception_handler(KontextualError)
async def kontextual_error_handler(_request, exc: KontextualError) -> JSONResponse:
+ if _is_cmis_request(_request):
+ return _cmis_error_response(400, _error_payload(exc))
return JSONResponse(status_code=400, content=_error_payload(exc))
+ @app.exception_handler(HTTPException)
+ async def http_exception_handler(_request, exc: HTTPException) -> JSONResponse:
+ if _is_cmis_request(_request):
+ detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
+ return _cmis_error_response(exc.status_code, detail)
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
+
@app.get("/health", tags=["system"])
def health() -> dict[str, Any]:
return runtime.health()
@@ -3208,7 +3516,7 @@ def create_app(runtime: ServiceRuntime | None = None):
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)
+ is_partial = start > 0 or requested_length is not None
content_length = max(representation.size_bytes - start, 0)
if requested_length is not None:
content_length = min(content_length, requested_length)
@@ -3337,6 +3645,14 @@ def create_app(runtime: ServiceRuntime | None = None):
context,
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
)
+ if action in {"createDocumentFromSource", "copy"}:
+ return response(
+ runtime.cmis_browser_create_document_from_source,
+ access_point_id,
+ payload,
+ context,
+ parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
+ )
if action in {"delete", "deleteObject"}:
if not object_id:
raise ValidationError("CMIS object id is required", details={"operation": "deleteObject"})
@@ -3363,8 +3679,10 @@ def create_app(runtime: ServiceRuntime | None = None):
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)
+ response(runtime.cmis_delete_content_stream, access_point_id, object_id, context, payload)
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
+ if action in {"bulkUpdateProperties", "bulkUpdate"}:
+ return response(runtime.cmis_bulk_update_properties, access_point_id, payload, context)
raise ValidationError(
"Unsupported CMIS Browser Binding action",
details={
@@ -3372,6 +3690,7 @@ def create_app(runtime: ServiceRuntime | None = None):
"supported": [
"createFolder",
"createDocument",
+ "createDocumentFromSource",
"delete",
"deleteObject",
"deleteTree",
@@ -4408,6 +4727,28 @@ def _cmis_browser_properties(payload: dict[str, Any]) -> dict[str, Any]:
return properties
+def _cmis_browser_bulk_entries(payload: dict[str, Any]) -> list[dict[str, str | None]]:
+ object_ids: dict[str, str] = {}
+ change_tokens: dict[str, str | None] = {}
+ for key, value in payload.items():
+ if key.startswith("objectId["):
+ index = key[len("objectId[") :].split("]", 1)[0]
+ object_ids[index] = str(value)
+ elif key.startswith("changeToken["):
+ index = key[len("changeToken[") :].split("]", 1)[0]
+ token = str(value)
+ change_tokens[index] = token or None
+
+ def sort_key(index: str) -> tuple[int, str]:
+ return (int(index), index) if index.isdigit() else (10_000, index)
+
+ return [
+ {"object_id": object_ids[index], "change_token": change_tokens.get(index)}
+ for index in sorted(object_ids, key=sort_key)
+ if object_ids[index]
+ ]
+
+
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
return AuthorizationError(
"CMIS operation denied by access-point profile",
diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py
index 582f43f..ff27a9a 100644
--- a/src/kontextual_engine/core/cmis.py
+++ b/src/kontextual_engine/core/cmis.py
@@ -117,6 +117,7 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset(
CMISAction.DELETE_OBJECT,
CMISAction.DELETE_TREE,
CMISAction.SET_CONTENT_STREAM,
+ CMISAction.BULK_UPDATE_PROPERTIES,
}
)
MUTATION_ACTIONS = {
@@ -304,6 +305,7 @@ class CMISAccessProfile:
+ (
CMISCapability.OBJECT_WRITE,
CMISCapability.CONTENT_STREAM_WRITE,
+ CMISCapability.BULK_UPDATE,
),
allow_mutations=True,
metadata={"compatibility": "selected-opencmis-tck-browser-subset"},
diff --git a/tests/cmis/test_cmis_access_profiles.py b/tests/cmis/test_cmis_access_profiles.py
index 7303570..21d8b30 100644
--- a/tests/cmis/test_cmis_access_profiles.py
+++ b/tests/cmis/test_cmis_access_profiles.py
@@ -59,6 +59,13 @@ def test_governed_authoring_profile_allows_selected_write_actions() -> None:
assert profile.decide_action(CMISAction.APPLY_ACL, context).reason == "cmis_operation_not_implemented"
+def test_compat_tck_profile_allows_bulk_update_for_browser_binding_maturity() -> None:
+ profile = CMISAccessProfile.compat_tck()
+ context = _context()
+
+ assert profile.decide_action(CMISAction.BULK_UPDATE_PROPERTIES, context).allowed is True
+
+
def test_profiles_hide_denied_sensitivities_without_partial_exposure() -> None:
profile = CMISAccessProfile.readonly_browser()
context = _context()
diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py
index 964a3d6..8071db1 100644
--- a/tests/cmis/test_cmis_browser_binding_api.py
+++ b/tests/cmis/test_cmis_browser_binding_api.py
@@ -213,8 +213,9 @@ def test_cmis_query_reports_unsupported_subset_diagnostics(cmis_client) -> None:
params={"q": "SELECT * FROM cmis:document JOIN cmis:relationship"},
)
- assert response.status_code == 422
- assert response.json()["detail"]["details"]["supported"] == [
+ assert response.status_code == 400
+ assert response.json()["exception"] == "invalidArgument"
+ assert response.json()["details"]["supported"] == [
"SELECT * FROM cmis:document",
"SELECT * FROM kontextual:document",
]
@@ -245,6 +246,10 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
"/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
params={"offset": 2, "length": 4},
)
+ byte_offset_zero = cmis_client.get(
+ "/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
+ params={"offset": 0},
+ )
deleted = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/delete",
json={},
@@ -258,6 +263,9 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
assert byte_stream.headers["etag"].startswith("sha256:")
assert byte_range.content == b"Upda"
assert byte_range.headers["content-length"] == "4"
+ assert byte_offset_zero.status_code == 200
+ assert byte_offset_zero.content == b"# Updated"
+ assert "content-range" not in byte_offset_zero.headers
assert deleted.json()["lifecycle"] == "delete_requested"
@@ -286,8 +294,9 @@ def test_cmis_browser_binding_create_document_validates_type_and_secondary_ids(c
files={"content": ("secondary.txt", b"Secondary content", "text/plain")},
)
- assert invalid.status_code == 422
- assert invalid.json()["detail"]["details"]["type_id"] == "cmis:folder"
+ assert invalid.status_code == 400
+ assert invalid.json()["exception"] == "invalidArgument"
+ assert invalid.json()["details"]["type_id"] == "cmis:folder"
assert created.status_code == 200
assert created.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == ["kontextual:secondary"]
@@ -326,6 +335,227 @@ def test_cmis_browser_binding_document_without_content_streams_empty_compatibili
assert content.headers["content-length"] == "0"
+def test_cmis_browser_binding_delete_content_stream_tombstones_content(cmis_client) -> None:
+ document = 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]": "Delete Content Document",
+ },
+ files={"content": ("delete-content.txt", b"Delete me", "text/plain")},
+ ).json()
+ object_id = document["properties"]["cmis:objectId"]["value"]
+ token = document["properties"]["cmis:changeToken"]["value"]
+
+ deleted = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={"cmisaction": "deleteContentStream", "objectId": object_id, "changeToken": token},
+ )
+ content_after_delete = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "content", "objectId": object_id},
+ )
+
+ assert deleted.status_code == 200
+ assert deleted.json()["properties"]["cmis:contentStreamLength"]["value"] is None
+ assert deleted.json()["properties"]["cmis:changeToken"]["value"] != token
+ assert content_after_delete.status_code == 409
+ assert content_after_delete.json()["exception"] == "constraint"
+
+
+def test_cmis_browser_binding_change_tokens_conflict_on_stale_updates(cmis_client) -> None:
+ document = 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]": "Token Document",
+ },
+ files={"content": ("token.txt", b"Token content", "text/plain")},
+ ).json()
+ object_id = document["properties"]["cmis:objectId"]["value"]
+ original_token = document["properties"]["cmis:changeToken"]["value"]
+ renamed = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "updateProperties",
+ "objectId": object_id,
+ "changeToken": original_token,
+ "propertyId[0]": "cmis:name",
+ "propertyValue[0]": "Token Document Renamed",
+ },
+ )
+ renamed_token = renamed.json()["properties"]["cmis:changeToken"]["value"]
+ stale_property_update = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "updateProperties",
+ "objectId": object_id,
+ "changeToken": original_token,
+ "propertyId[0]": "cmis:description",
+ "propertyValue[0]": "stale",
+ },
+ )
+ content_updated = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "setContentStream",
+ "objectId": object_id,
+ "changeToken": renamed_token,
+ "content": "Updated token content",
+ "media_type": "text/plain",
+ },
+ )
+ stale_content_update = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "setContentStream",
+ "objectId": object_id,
+ "changeToken": renamed_token,
+ "content": "Stale update",
+ "media_type": "text/plain",
+ },
+ )
+
+ assert renamed.status_code == 200
+ assert renamed_token != original_token
+ assert stale_property_update.status_code == 409
+ assert stale_property_update.json()["exception"] == "updateConflict"
+ assert content_updated.status_code == 200
+ assert content_updated.json()["properties"]["cmis:changeToken"]["value"] != renamed_token
+ assert stale_content_update.status_code == 409
+ assert stale_content_update.json()["exception"] == "updateConflict"
+
+
+def test_cmis_browser_binding_create_document_from_source_reuses_content_projection(cmis_client) -> None:
+ source = 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]": "Copy Source",
+ },
+ files={"content": ("copy-source.txt", b"Source copy bytes", "text/plain")},
+ ).json()
+ 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]": "Copy Destination",
+ },
+ ).json()
+ source_id = source["properties"]["cmis:objectId"]["value"]
+ folder_id = folder["properties"]["cmis:objectId"]["value"]
+ copied = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createDocumentFromSource",
+ "objectId": folder_id,
+ "sourceId": source_id,
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:document",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "Copied Document",
+ },
+ )
+ copied_id = copied.json()["properties"]["cmis:objectId"]["value"]
+ copied_content = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "content", "objectId": copied_id},
+ )
+
+ assert copied.status_code == 200
+ assert copied_id != source_id
+ assert copied.json()["properties"]["cmis:name"]["value"] == "Copied Document"
+ assert copied.json()["properties"]["cmis:contentStreamLength"]["value"] == 17
+ assert copied_content.content == b"Source copy bytes"
+
+
+def test_cmis_browser_binding_bulk_update_renames_documents(cmis_client) -> None:
+ folder_one = 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]": "Bulk Folder One",
+ },
+ ).json()
+ folder_two = 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]": "Bulk Folder Two",
+ },
+ ).json()
+ first = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createDocument",
+ "objectId": folder_one["properties"]["cmis:objectId"]["value"],
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:document",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "Bulk One",
+ },
+ files={"content": ("bulk-one.txt", b"bulk one", "text/plain")},
+ ).json()
+ second = cmis_client.post(
+ "/cmis/compat-tck/browser/root",
+ data={
+ "cmisaction": "createDocument",
+ "objectId": folder_two["properties"]["cmis:objectId"]["value"],
+ "propertyId[0]": "cmis:objectTypeId",
+ "propertyValue[0]": "cmis:document",
+ "propertyId[1]": "cmis:name",
+ "propertyValue[1]": "Bulk Two",
+ },
+ files={"content": ("bulk-two.txt", b"bulk two", "text/plain")},
+ ).json()
+ first_id = first["properties"]["cmis:objectId"]["value"]
+ second_id = second["properties"]["cmis:objectId"]["value"]
+ response = cmis_client.post(
+ "/cmis/compat-tck/browser",
+ data={
+ "cmisaction": "bulkUpdate",
+ "objectId[0]": first_id,
+ "changeToken[0]": first["properties"]["cmis:changeToken"]["value"],
+ "objectId[1]": second_id,
+ "changeToken[1]": second["properties"]["cmis:changeToken"]["value"],
+ "propertyId[0]": "cmis:name",
+ "propertyValue[0]": "Bulk Renamed",
+ },
+ )
+ first_after = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "object", "objectId": first_id},
+ ).json()
+ second_after = cmis_client.get(
+ "/cmis/compat-tck/browser/root",
+ params={"cmisselector": "object", "objectId": second_id},
+ ).json()
+
+ assert response.status_code == 200
+ assert {item["id"] for item in response.json()} == {first_id, second_id}
+ assert all(item["changeToken"] for item in response.json())
+ assert first_after["properties"]["cmis:name"]["value"] == "Bulk Renamed"
+ assert second_after["properties"]["cmis:name"]["value"] == "Bulk Renamed"
+
+
def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None:
created = cmis_client.post(
"/cmis/compat-tck/browser/root",
@@ -523,9 +753,10 @@ def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis
json={"properties": {"cmis:objectTypeId": "cmis:folder"}},
)
- assert response.status_code == 422
- assert response.json()["detail"]["details"]["property"] == "cmis:objectTypeId"
- assert response.json()["detail"]["details"]["supported"] == [
+ assert response.status_code == 400
+ assert response.json()["exception"] == "invalidArgument"
+ assert response.json()["details"]["property"] == "cmis:objectTypeId"
+ assert response.json()["details"]["supported"] == [
"cmis:name",
"cmis:description",
"cmis:secondaryObjectTypeIds",
diff --git a/workplans/KONT-WP-0014-cmis-object-content-maturity.md b/workplans/KONT-WP-0014-cmis-object-content-maturity.md
index 5ea229d..40fc384 100644
--- a/workplans/KONT-WP-0014-cmis-object-content-maturity.md
+++ b/workplans/KONT-WP-0014-cmis-object-content-maturity.md
@@ -10,7 +10,7 @@ topic_slug: markitect
planning_priority: high
planning_order: 14
created: "2026-05-08"
-updated: "2026-05-08"
+updated: "2026-05-13"
state_hub_workstream_id: "ccfa90ee-be23-499b-a727-451a0d289df7"
---
@@ -196,6 +196,46 @@ Current external frontier:
- `createDocumentFromSource`/copy remains unsupported.
- Offset-zero range requests are still marked partial.
+## Implementation Evidence - 2026-05-13T21:02:55Z
+
+Evidence file:
+
+- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md`
+
+Implemented in this pass:
+
+- CMIS Browser Binding exception mapping for `invalidArgument`, `constraint`,
+ `updateConflict`, `notSupported`, `objectNotFound`, and `permissionDenied`.
+- `bulkUpdateProperties` for the `compat-tck` profile, using the existing
+ CMIS property update path and per-object change-token checks.
+- Stronger `deleteContentStream` tombstone semantics with content-change
+ versioning and CMIS `constraint` responses for post-delete stream reads.
+- Change-token conflict handling for property/content mutations.
+- `createDocumentFromSource`/copy using representation blob-reference reuse.
+- Offset-zero range classification as a full-stream response.
+
+Latest verification:
+
+- Internal CMIS suite: `.venv/bin/python -m pytest tests/cmis -q`
+ -> `55 passed`.
+- Full suite: `.venv/bin/python -m pytest -q` -> `165 passed`,
+ `14 skipped`.
+- OpenCMIS: `run-20260513T210255Z` in
+ `/tmp/open-cmis-tck-kontextual-20260513T230205Z`.
+- Guide Board status: `completed`, with `0` unexpected findings.
+- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`.
+- `object-content`: `10 info`, `5 skipped`, `1 warning`.
+
+Current external frontier:
+
+- The selected OpenCMIS Browser Binding repository/type and object/content
+ baseline is completed with warnings only.
+- Remaining warnings are local HTTP instead of HTTPS and unsupported
+ `appendContentStream()`.
+- Further maturity work should move to deliberately selected optional CMIS
+ areas: query depth, descendants/folder-tree read navigation, version-history
+ reads, relationship service depth, ACL detail, and rendition projection.
+
## D14.1 - Define CMIS maturity boundary and TCK profile semantics
```task
@@ -277,7 +317,7 @@ Acceptance:
```task
id: KONT-WP-0014-T005
-status: in_progress
+status: done
priority: medium
state_hub_task_id: "5feb6db8-24eb-4c20-8c3e-d530f396ef6a"
```
@@ -296,8 +336,9 @@ 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
+- Done for `deleteContentStream` tombstone semantics and offset-zero range
classification.
+- `appendContentStream()` remains explicitly unsupported by design.
## D14.6 - Add natural navigation and query depth
@@ -347,7 +388,32 @@ Acceptance:
Progress:
- Started by isolating OpenCMIS change-token failures as the main T007 maturity
- gap. Relationship and ACL discovery were not expanded in this pass.
+ gap.
+- Done for CMIS property/content mutation change-token conflict handling.
+- Relationship and ACL discovery were not expanded in this pass.
+
+## D14.9 - Resolve OpenCMIS object/content blocker set
+
+```task
+id: KONT-WP-0014-T009
+status: done
+priority: high
+state_hub_task_id: "3c075537-e05f-4240-acab-18c1d60a8efe"
+```
+
+Acceptance:
+
+- Resolve or explicitly classify the remaining OpenCMIS object/content
+ blockers: CMIS exception mapping, `bulkUpdateProperties`,
+ `deleteContentStream`, change-token conflict handling,
+ `createDocumentFromSource`/copy, and offset-zero range classification.
+- Rerun the OpenCMIS `repository-type` and `object-content` baseline.
+- Persist the timestamped results and update the CMIS scorecard.
+
+Progress:
+
+- Done. The selected baseline now completes with warnings only and no
+ unexpected findings.
## D14.8 - Expand OpenCMIS assessment and update maturity scorecard