From b11c9189e4de224948e331537d7be39029a8bff9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 13 May 2026 23:42:56 +0200 Subject: [PATCH] More CMIS scoring optimization --- docs/cmis-1-1-capability-scorecard.md | 73 ++-- ...-tck-wp0014-evidence-2026-05-13T210255Z.md | 75 ++++ src/kontextual_engine/api/app.py | 351 +++++++++++++++++- src/kontextual_engine/core/cmis.py | 2 + tests/cmis/test_cmis_access_profiles.py | 7 + tests/cmis/test_cmis_browser_binding_api.py | 245 +++++++++++- ...NT-WP-0014-cmis-object-content-maturity.md | 74 +++- 7 files changed, 775 insertions(+), 52 deletions(-) create mode 100644 docs/cmis-opencmis-tck-wp0014-evidence-2026-05-13T210255Z.md 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