More CMIS scoring optimization

This commit is contained in:
2026-05-13 23:42:56 +02:00
parent 852b45c158
commit b11c9189e4
7 changed files with 775 additions and 52 deletions

View File

@@ -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.<br>- Unsupported feature catalog exists.<br>- 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.<br>- No mutable types or custom schema/type management.<br>- 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.<br>- Projection-only parents exist.<br>- 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.<br>- Deleted/hidden objects are now correctly not exposed.<br>- 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.<br>- No bulk update, copy/create-from-source, broad filing mutation, or physical delete semantics.<br>- 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.<br>- Digest verification and governed access exist.<br>- 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.<br>- Unsupported feature catalog exists.<br>- 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.<br>- No mutable types or custom schema/type management.<br>- 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.<br>- Projection-only parents exist.<br>- 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.<br>- Deleted/hidden objects are correctly not exposed.<br>- 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.<br>- No broad filing mutation, raw physical delete, checkout/checkin, or policy/item creation.<br>- 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.<br>- Digest verification and governed access exist.<br>- Remaining warning is unsupported append semantics. |
| Versioning service | 8 | 25% | Hyland Alfresco ACS | - Version properties can be projected from engine versions.<br>- No checkout/checkin/cancelCheckout/PWC services.<br>- No version history route or all-versions query behavior. |
| Discovery/query | 8 | 25% | Hyland Alfresco ACS | - Narrow document select subset exists.<br>- Unsupported joins/order-by return diagnostics.<br>- 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.<br>- Visibility gates prevent protected relationship leakage.<br>- 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.<br>- `applyACL` is blocked as not implemented.<br>- Missing inherited/direct ACL fidelity, propagation, ACL mutation, and repository principal model. |
| Policy service | 3 | 10% | Hyland Alfresco ACS | - Native policy decisions govern exposure.<br>- No CMIS policy objects, `applyPolicy`, `removePolicy`, or `getAppliedPolicies` service surface.<br>- Explicitly unsupported. |
| Change log | 5 | 55% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.<br>- Missing CMIS update-conflict behavior for reused change tokens and richer change event typing.<br>- 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.<br>- CMIS change-token conflicts are now enforced for property/content mutations.<br>- 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.<br>- Standard CMIS `capabilityMultifiling` is correctly false.<br>- No add/remove filing mutations or canonical folder membership model. |
| Renditions | 3 | 15% | Hyland Alfresco ACS | - Native representations could become rendition candidates later.<br>- CMIS rendition capability is currently `none`.<br>- No rendition taxonomy or rendition stream routes. |
| Retention and hold | 2 | 5% | OpenText / Hyland governance stacks | - Native governance metadata can represent intent later.<br>- No CMIS retention/hold model or mutation services.<br>- Explicitly unsupported. |
| Bulk update | 2 | 5% | Hyland Alfresco ACS | - Native batch/error envelopes exist elsewhere in the engine.<br>- No CMIS `bulkUpdateProperties` behavior.<br>- 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.<br>- Optional actions and CMIS exception mapping remain incomplete.<br>- 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.<br>- It is intentionally narrow and not enabled on the normal governed-authoring profile yet.<br>- 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.<br>- Selected OpenCMIS Browser Binding repository/type and object/content baseline completes with warnings only.<br>- 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.<br>- Intentionally deferred until monetized need. |
| Web Services binding | 2 | 0% | Hyland Alfresco ACS | - No SOAP/WSDL stack.<br>- 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`.<br>- Selected `repository-type` baseline is warning-only.<br>- `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`.<br>- Selected `repository-type` and `object-content` baselines complete with warnings only.<br>- 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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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"},

View File

@@ -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()

View File

@@ -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",

View File

@@ -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