CMIS Browser Binding fixes

This commit is contained in:
2026-05-11 12:28:36 +02:00
parent 59aa2a49a8
commit dc32be36fb
8 changed files with 1422 additions and 121 deletions

View File

@@ -2,16 +2,19 @@
Date: 2026-05-07
Evidence update: the 2026-05-08 OpenCMIS TCK compatibility implementation
resolved the initial Browser Binding session blocker. The latest run,
`run-20260508T092113Z`, completed the selected baseline with `38` passing
repository/type cases, one local HTTP transport warning, and `22` object/content
skips caused by the current non-creatable folder profile. See
`docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md`.
Evidence update: the 2026-05-08 WP-0014 OpenCMIS pass moved the selected
baseline beyond the original folder-creatable skip boundary and through several
object/content maturity issues. The latest run, `run-20260508T164334Z`, reports
`repository-type` as warning-only and reduces `object-content` to specific
semantic gaps: invalid-type exception mapping, bulk update, content deletion,
change-token conflict handling, copy/create-from-source, and one range
classification warning. See
`docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md`.
The score below remains a product-depth estimate against mature CMIS products;
the evidence-backed TCK preparation score for the selected baseline is `23.81`
with `2/9` capability groups covered.
The score below remains a product-depth estimate against mature CMIS products.
The selected OpenCMIS baseline still exits as `infrastructure_error`, but the
failure frontier is now narrow and capability-specific rather than basic
Browser Binding shape or navigation.
Status: baseline scorecard for the current Browser Binding subset.
@@ -71,9 +74,9 @@ CMIS interoperability importance rather than engine-internal importance.
| Metric | Score |
| --- | ---: |
| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 42% |
| Controlled-client Browser Binding usefulness | 58% |
| Broad commodity CMIS client compatibility | 35% |
| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 51% |
| Controlled-client Browser Binding usefulness | 70% |
| Broad commodity CMIS client compatibility | 46% |
Interpretation: the current CMIS layer is a credible Browser Binding subset for
known clients and profile-specific integrations. It is not yet a broad ECM/CMIS
@@ -83,41 +86,46 @@ replacement surface.
| CMIS capability area | Weight | Current depth | Most worthy contender | Gap basis behind the percentage |
| --- | ---: | ---: | --- | --- |
| Repository service and repository info | 5 | 75% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.<br>- Unsupported feature catalog exists.<br>- Missing exact service-document parity and external TCK evidence. |
| Type definitions | 6 | 45% | Hyland Alfresco ACS | - Base types and content stream properties exist.<br>- No mutable types or custom schema/type management.<br>- No broad property definition model beyond current projected fields. |
| Navigation service | 8 | 40% | Hyland Alfresco ACS | - Root and folder-scoped children exist.<br>- Projection-only parents exist.<br>- Missing `getDescendants`, `getFolderTree`, object-by-path parity, and real filing mutations. |
| Object read service | 10 | 70% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, and allowable actions exist.<br>- Missing selector/property-filter fidelity and full Browser Binding response parity.<br>- Deleted/hidden objects are now correctly not exposed. |
| Object write service | 8 | 35% | Hyland Alfresco ACS | - `createDocument`, custom metadata updates, content stream set, and delete-request lifecycle exist.<br>- No createFolder, moveObject, standard `cmis:*` property mutation, or physical delete semantics.<br>- Delete is intentionally governed, not raw repository removal. |
| Content stream read/write | 8 | 65% | Hyland Alfresco ACS | - Byte streaming and deduplicating `setContentStream` exist.<br>- Digest verification and governed access exist.<br>- Missing append/delete stream, multipart Browser Binding parity, range handling, and client-tested large stream workflows. |
| Repository service and repository info | 5 | 82% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.<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. |
| 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 full change token durability semantics and richer change event typing.<br>- Not yet proven against external CMIS clients. |
| 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. |
| 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 | 45% | Hyland Alfresco ACS | - Browser-style routes and JSON envelopes exist.<br>- FastAPI route shapes are pragmatic, not complete CMIS Browser Binding selector/action parity.<br>- Route-level tests skip without optional service dependencies. |
| Browser Binding protocol fidelity | 7 | 66% | Hyland Alfresco ACS | - Browser-style routes, JSON envelopes, action aliases, multipart forms, path-addressed root routes, property filters, path segments, and range responses exist.<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. |
| 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 | 35% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation now succeeds against `compat-tck`.<br>- Selected `repository-type` baseline completes with no failures and one local HTTP warning.<br>- `object-content` reaches parsed cases but skips because `cmis:folder` is not creatable; broader groups and third-party client matrix are still missing. |
| External conformance evidence | 3 | 58% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.<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. |
Weighted result from this table: **42%**.
Weighted result from this table: **51%**.
## Most Important Gaps
1. **External conformance expansion**
- Keep the selected OpenCMIS TCK baseline running against `compat-tck`.
- Decide whether to add TCK-only `createFolder` support or keep CRUD/content
skips as a deliberate profile boundary.
- Expand selected groups after the supported capability boundary is agreed.
- Close or explicitly waive the remaining object/content gaps: exception
mapping, bulk update, delete content, change-token conflicts, copy, and
offset-zero range classification.
- Expand selected groups after the supported object/content baseline is
stable.
2. **Browser Binding fidelity**
- Align route/action/selector shapes more closely with CMIS Browser Binding.
- Add non-skipped FastAPI route tests in CI with service extras installed.
- Add client smoke tests with Apache Chemistry/OpenCMIS where feasible.
- Return CMIS-specific exception classes/statuses instead of generic runtime
exceptions where OpenCMIS distinguishes invalid argument, constraint, and
update conflict.
3. **Query depth**
- Add a real CMIS SQL subset parser instead of a two-query allowlist.

View File

@@ -0,0 +1,99 @@
# CMIS WP-0014 OpenCMIS Evidence - 2026-05-08T15:33:16Z
## Scope
This note records the second WP-0014 maturity pass for the CMIS 1.1 Browser
Binding adapter. The pass followed the earlier folder/action work and focused
on OpenCMIS compatibility issues that fit the engine architecture naturally.
## Implemented Since Prior Evidence
- Added Browser Binding `cmisaction=update` as an alias for
`updateProperties`.
- Added scoped `cmisaction=move` support for CMIS-authored workspace documents
and adapter-managed workspace folders.
- Added URL-path Browser Binding routes under `/browser/root/{path}` for object,
children, parent, parents, properties, allowable actions, content, and POST
actions against path-addressed objects.
- Normalized stored CMIS content stream media types and emitted explicit
`Content-Type` headers so text streams do not acquire an unwanted charset.
- Accepted metadata-backed standard property updates for `cmis:name`,
`cmis:description`, and `cmis:secondaryObjectTypeIds`.
- Honored `cmis:secondaryObjectTypeIds` during document creation.
- Rejected invalid `createDocument` type ids, such as `cmis:folder`.
- Removed non-standard `cmis:path` from document property definitions and
document object projections while retaining folder `cmis:path`.
## Local Verification
Focused CMIS suite:
```bash
.venv/bin/python -m pytest tests/cmis -q
```
Result:
- `48 passed`
- Performance monitor warnings appeared on two Browser Binding tests; these
should be watched across additional runs before treating them as a regression.
## OpenCMIS Run
The final run used an isolated temporary target on port `8010` to avoid
colliding with other local services:
```bash
cd /home/worsch/guide-board
source /home/worsch/open-cmis-tck/.local/toolchains/env.sh
PYTHONPATH=src python3 -m guide_board \
--extension-dir ../open-cmis-tck \
run \
--target /tmp/kontextual-cmis-compat-8010.json \
--assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \
--output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z
```
Result:
- Run ID: `run-20260508T153316Z`
- Run directory: `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z`
- Harness status: `infrastructure_error`
- Maturity scorecard:
`/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z/reports/cmis-maturity-scorecard.md`
- Maturity score: `19.05`
- Coverage: `2/9` groups
## Capability Interpretation
- `repository-type`: improved from failing to `warning` / partial. Repository
info and type metadata are now usable enough for the selected baseline, with
warnings instead of hard failures.
- `object-content`: still infrastructure-blocked in guide-board classification,
but the blockers are now concrete object/content edge cases rather than
Browser Binding startup, folder creation, MIME, or secondary-type basics.
## Remaining Frontier
- `getObjectByPath` still fails in several OpenCMIS child checks for paths such
as `/test-folder/test-folder`; this likely needs tighter folder/document
child path-segment semantics and cleanup behavior investigation.
- Creating a document without content still leads OpenCMIS into a missing
content-stream exception. We need decide whether CMIS-created no-content
documents should expose an empty stream or clearer no-content semantics.
- Invalid-type errors are now correctly rejected, but OpenCMIS still classifies
our HTTP 422 as a runtime warning rather than a clean CMIS constraint
response.
- Folder `cmis:path` still appears when property filters request narrower
property sets. Operation-context filtering for properties, ACLs, allowable
actions, and path segments remains a compatibility gap.
- `bulkUpdate`, `deleteContent`, and some change-token tests still receive
unsupported-action or validation responses. These should be handled either by
natural implementation or sharper unsupported behavior.
## Conclusion
The CMIS adapter foundation is sounder and more standards-shaped after this
pass. We should keep WP-0014 active for the remaining object/content frontier,
but the work has moved from "basic Browser Binding compatibility" into specific
CMIS behavior polish and optional-service boundary decisions.

View File

@@ -0,0 +1,109 @@
# CMIS OpenCMIS TCK Evidence - WP-0014 - 2026-05-08T16:43:34Z
## Run
- Run ID: `run-20260508T164334Z`
- Harness: `guide-board` with external extension `/home/worsch/open-cmis-tck`
- Target: `kontextual-cmis-compat`
- Endpoint under test: `http://127.0.0.1:8010/cmis/compat-tck/browser`
- Output directory:
`/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z`
- Assessment package:
`/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z/reports/assessment-package.json`
- Report:
`/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z/reports/report.md`
Command shape:
```sh
cd /home/worsch/guide-board
PYTHONPATH=src python3 -m guide_board \
--extension-dir ../open-cmis-tck \
run \
--target /tmp/kontextual-cmis-compat-8010.json \
--assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \
--output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z
```
The local Java/Maven toolchain from `/home/worsch/open-cmis-tck/.local/toolchains`
was supplied in the command environment.
## Internal Verification
- Focused CMIS tests:
`.venv/bin/python -m pytest tests/cmis/test_cmis_runtime_browser_binding.py tests/cmis/test_cmis_browser_binding_api.py -q`
-> `20 passed`.
- Full suite:
`.venv/bin/python -m pytest -q`
-> `160 passed, 14 skipped`.
## OpenCMIS Result Summary
Overall Guide Board status remains `infrastructure_error` because the
`object-content` group still contains unsupported or incomplete CMIS services.
Normalized group counts:
- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`.
- `object-content`: `12 info`, `5 skipped`, `3 warning`, `3 fail`,
`3 infrastructure_error`.
The sole `repository-type` warning is local HTTP rather than HTTPS:
`HTTPS is not used. Credentials might be transferred as plain text!`
## Improvements Since `run-20260508T153316Z`
- Fixed Browser Binding parent `relativePathSegment`; OpenCMIS no longer builds
invalid paths such as `/folder/folder` for documents.
- Added Browser Binding property filtering and optional envelope trimming for
`filter`, `includeAllowableActions`, `includeACL`, and `includePathSegment`.
- Changed Browser Binding children `numItems` to report the total child count
instead of page length.
- Added range-aware content responses with sliced bodies, `Content-Length`,
`Content-Range`, and `206` for partial requests.
- Added no-content document compatibility streams while keeping document content
stream properties nullable.
- Added stable object-id behavior for adapter-managed folder rename/update.
- Added `setContent` and `deleteContent` Browser Binding action aliases over the
existing content-stream service boundary.
- Preserved existing blob deduplication and digest verification paths.
Removed or reduced OpenCMIS frontier items:
- `getObjectByPath` path-segment failures are gone.
- Create/delete document paging `numItems` failures are gone.
- Update Smoke Test folder rename/object-id failures are gone.
- Operation Context ACL delivery warning is gone.
- Most content range warnings are gone.
- No-content document retrieval no longer aborts with `Representation not found`.
## Remaining Frontier
These are now the concrete maturity gaps visible in the selected OpenCMIS
baseline:
- **Invalid type exception mapping**: invalid document/folder type creation
returns HTTP `422`, which OpenCMIS reports as `CmisRuntimeException` instead
of a narrower CMIS invalid-argument/constraint exception.
- **Bulk update**: `cmisaction=bulkUpdate` is still unsupported and reports
`Unprocessable Entity`.
- **Delete content semantics**: `deleteContentStream` is accepted through the
alias layer, but OpenCMIS still observes content after delete. The adapter
needs a stronger natural representation-removal or tombstone model.
- **Change tokens**: repeated property/content updates with the same change token
do not produce CMIS update-conflict behavior yet.
- **Copy/create-from-source**: `createDocumentFromSource` remains unsupported.
- **Range classification**: the remaining range warning is for an offset-zero
full-stream request being marked as partial.
## Interpretation
The CMIS layer is now past the early Browser Binding and basic object/content
shape problems. The remaining failures are concentrated in specific CMIS
service semantics: exception mapping, content deletion, change token conflict
handling, copy support, and bulk update.
This is a healthy architectural signal: the adapter continues to map naturally
onto native asset, representation, blob, metadata, policy, and audit services.
The remaining work should be handled as explicit compatibility choices rather
than broad ECM reimplementation.

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@ class CMISAction(str, Enum):
CREATE_FOLDER = "create_folder"
CREATE_DOCUMENT = "create_document"
UPDATE_PROPERTIES = "update_properties"
MOVE_OBJECT = "move_object"
DELETE_OBJECT = "delete_object"
DELETE_TREE = "delete_tree"
SET_CONTENT_STREAM = "set_content_stream"
@@ -89,6 +90,7 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = {
CMISAction.CREATE_FOLDER: CMISCapability.OBJECT_WRITE,
CMISAction.CREATE_DOCUMENT: CMISCapability.OBJECT_WRITE,
CMISAction.UPDATE_PROPERTIES: CMISCapability.OBJECT_WRITE,
CMISAction.MOVE_OBJECT: CMISCapability.OBJECT_WRITE,
CMISAction.DELETE_OBJECT: CMISCapability.OBJECT_WRITE,
CMISAction.DELETE_TREE: CMISCapability.OBJECT_WRITE,
CMISAction.SET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_WRITE,
@@ -111,6 +113,7 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset(
CMISAction.CREATE_FOLDER,
CMISAction.CREATE_DOCUMENT,
CMISAction.UPDATE_PROPERTIES,
CMISAction.MOVE_OBJECT,
CMISAction.DELETE_OBJECT,
CMISAction.DELETE_TREE,
CMISAction.SET_CONTENT_STREAM,
@@ -120,6 +123,7 @@ MUTATION_ACTIONS = {
CMISAction.CREATE_DOCUMENT,
CMISAction.CREATE_FOLDER,
CMISAction.UPDATE_PROPERTIES,
CMISAction.MOVE_OBJECT,
CMISAction.DELETE_OBJECT,
CMISAction.DELETE_TREE,
CMISAction.SET_CONTENT_STREAM,
@@ -884,7 +888,6 @@ class CMISDomainMapper:
"cmis:changeToken": asset.current_version_id,
"cmis:description": asset.metadata.get("description", asset.title),
"cmis:secondaryObjectTypeIds": list(asset.metadata.get("cmis_secondary_object_type_ids", ())),
"cmis:path": self.asset_path(asset),
"cmis:contentStreamLength": content_stream.get("length") if content_stream else None,
"cmis:contentStreamMimeType": content_stream.get("mime_type") if content_stream else None,
"cmis:contentStreamFileName": content_stream.get("file_name") if content_stream else None,
@@ -905,6 +908,10 @@ class CMISDomainMapper:
compacted.setdefault("cmis:createdBy", "system")
compacted.setdefault("cmis:lastModifiedBy", "system")
compacted.setdefault("cmis:secondaryObjectTypeIds", [])
compacted.setdefault("cmis:contentStreamLength", None)
compacted.setdefault("cmis:contentStreamMimeType", None)
compacted.setdefault("cmis:contentStreamFileName", None)
compacted.setdefault("cmis:contentStreamId", None)
compacted.setdefault("kontextual:owner", "")
compacted.setdefault("kontextual:topics", [])
compacted.setdefault("kontextual:reviewState", "")
@@ -980,6 +987,7 @@ class CMISDomainMapper:
CMISAction.GET_RELATIONSHIPS,
CMISAction.GET_OBJECT_PARENTS,
CMISAction.UPDATE_PROPERTIES,
CMISAction.MOVE_OBJECT,
CMISAction.DELETE_OBJECT,
CMISAction.SET_CONTENT_STREAM,
]
@@ -1209,39 +1217,61 @@ def cmis_browser_type_definition_by_id(
raise KeyError(type_id)
def cmis_browser_object(projection: dict[str, Any]) -> dict[str, Any]:
properties = _browser_object_properties(projection)
def cmis_browser_object(
projection: dict[str, Any],
*,
property_filter: str | None = None,
include_allowable_actions: bool = True,
include_acl: bool = True,
) -> dict[str, Any]:
unfiltered_properties = _browser_object_properties(projection)
properties = _filter_browser_properties(unfiltered_properties, property_filter)
result = {
"properties": {
key: cmis_browser_property_value(key, value)
for key, value in properties.items()
},
"succinctProperties": properties,
"allowableActions": cmis_browser_allowable_actions(
projection.get("allowable_actions", []),
base_type_id=properties.get("cmis:baseTypeId"),
),
}
acl = projection.get("acl")
if include_allowable_actions:
result["allowableActions"] = cmis_browser_allowable_actions(
projection.get("allowable_actions", []),
base_type_id=unfiltered_properties.get("cmis:baseTypeId"),
)
acl = projection.get("acl") if include_acl else None
if isinstance(acl, dict) and acl:
result["acl"] = cmis_browser_acl(acl)
result["exactACL"] = bool(acl.get("is_exact", True))
return compact_dict(result)
def cmis_browser_object_in_folder_list(children: dict[str, Any]) -> dict[str, Any]:
def cmis_browser_object_in_folder_list(
children: dict[str, Any],
*,
property_filter: str | None = None,
include_allowable_actions: bool = True,
include_acl: bool = True,
include_path_segment: bool = True,
) -> dict[str, Any]:
objects = []
for item in children.get("objects", []):
entry = {
"object": cmis_browser_object(
item,
property_filter=property_filter,
include_allowable_actions=include_allowable_actions,
include_acl=include_acl,
),
}
if include_path_segment:
entry["pathSegment"] = item.get("name") or item.get("object_id")
objects.append(
{
"object": cmis_browser_object(item),
"pathSegment": item.get("name") or item.get("object_id"),
}
entry
)
return {
"objects": objects,
"hasMoreItems": bool(children.get("has_more_items", False)),
"numItems": int(children.get("num_items", len(objects))),
"numItems": int(children.get("total_num_items", children.get("num_items", len(objects)))),
}
@@ -1253,15 +1283,17 @@ def cmis_browser_object_list(objects: list[dict[str, Any]], *, has_more_items: b
}
def cmis_browser_parent_list(parents: dict[str, Any]) -> list[dict[str, Any]]:
def cmis_browser_parent_list(
parents: dict[str, Any],
*,
include_relative_path_segment: bool = True,
) -> list[dict[str, Any]]:
items = []
for parent in parents.get("parents", []):
items.append(
{
"object": cmis_browser_object(parent),
"relativePathSegment": parent.get("name"),
}
)
item = {"object": cmis_browser_object(parent)}
if include_relative_path_segment:
item["relativePathSegment"] = parent.get("relative_path_segment") or parent.get("name")
items.append(item)
return items
@@ -1270,7 +1302,7 @@ def cmis_browser_query_result(query_result: dict[str, Any]) -> dict[str, Any]:
return {
"results": results,
"hasMoreItems": bool(query_result.get("has_more_items", False)),
"numItems": int(query_result.get("num_items", len(results))),
"numItems": int(query_result.get("total_num_items", query_result.get("num_items", len(results)))),
}
@@ -1351,7 +1383,7 @@ def cmis_browser_allowable_actions(
"canGetFolderTree": False,
"canGetFolderParent": is_folder and CMISAction.GET_OBJECT_PARENTS.value in native,
"canGetRenditions": False,
"canMoveObject": False,
"canMoveObject": CMISAction.MOVE_OBJECT.value in native,
"canAddObjectToFolder": False,
"canRemoveObjectFromFolder": False,
"canCheckOut": False,
@@ -1362,7 +1394,7 @@ def cmis_browser_allowable_actions(
"canRemovePolicy": False,
"canGetAppliedPolicies": False,
"canApplyACL": False,
"canDeleteContentStream": False,
"canDeleteContentStream": CMISAction.SET_CONTENT_STREAM.value in native,
}
@@ -1600,6 +1632,23 @@ def _browser_object_properties(projection: dict[str, Any]) -> dict[str, Any]:
return properties
def _filter_browser_properties(properties: dict[str, Any], property_filter: str | None) -> dict[str, Any]:
if property_filter is None:
return properties
requested = {
part.strip()
for part in property_filter.split(",")
if part.strip()
}
if not requested or "*" in requested:
return properties
return {
key: value
for key, value in properties.items()
if key in requested or any(pattern.endswith(":*") and key.startswith(pattern[:-1]) for pattern in requested)
}
def _browser_property_type(property_id: str, value: Any) -> str:
if property_id in {
"cmis:objectId",
@@ -1677,11 +1726,12 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any
"cmis:lastModificationDate": {"property_type": "datetime", "cardinality": "single", "required": False},
"cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False},
"cmis:path": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:description": {"property_type": "string", "cardinality": "single", "required": False},
"kontextual:sensitivity": {"property_type": "string", "cardinality": "single", "required": False},
"kontextual:lifecycle": {"property_type": "string", "cardinality": "single", "required": False},
}
if base_type_id == CMISBaseType.FOLDER:
definitions["cmis:path"] = {"property_type": "string", "cardinality": "single", "required": False}
if base_type_id == CMISBaseType.DOCUMENT:
definitions.update(
{

View File

@@ -142,7 +142,7 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
assert root_object["properties"]["kontextual:workspaceFolder"]["value"] is False
assert "kontextual:assetId" in browser_type_definition["propertyDefinitions"]
assert browser_type_definition["propertyDefinitions"]["kontextual:topics"]["cardinality"] == "multi"
assert "cmis:path" in browser_type_definition["propertyDefinitions"]
assert "cmis:path" not in browser_type_definition["propertyDefinitions"]
assert {item["base_type_id"] for item in types["items"]} >= {
"cmis:document",
"cmis:folder",
@@ -236,11 +236,15 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
)
streamed = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/content",
json={"content": "# Updated", "media_type": "text/markdown"},
json={"content": "# Updated", "media_type": "text/plain; charset=utf-8"},
)
byte_stream = cmis_client.get(
"/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
)
byte_range = cmis_client.get(
"/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored",
params={"offset": 2, "length": 4},
)
deleted = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/delete",
json={},
@@ -248,12 +252,80 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) ->
assert created.status_code == 200
assert updated.json()["properties"]["kontextual:metadata:status"] == "draft"
assert streamed.json()["content_stream"]["mime_type"] == "text/markdown"
assert streamed.json()["content_stream"]["mime_type"] == "text/plain"
assert byte_stream.content == b"# Updated"
assert byte_stream.headers["content-type"] == "text/plain"
assert byte_stream.headers["etag"].startswith("sha256:")
assert byte_range.content == b"Upda"
assert byte_range.headers["content-length"] == "4"
assert deleted.json()["lifecycle"] == "delete_requested"
def test_cmis_browser_binding_create_document_validates_type_and_secondary_ids(cmis_client) -> None:
invalid = cmis_client.post(
"/cmis/compat-tck/browser/root",
data={
"cmisaction": "createDocument",
"propertyId[0]": "cmis:objectTypeId",
"propertyValue[0]": "cmis:folder",
"propertyId[1]": "cmis:name",
"propertyValue[1]": "Invalid Document",
},
)
created = cmis_client.post(
"/cmis/compat-tck/browser/root",
data={
"cmisaction": "createDocument",
"propertyId[0]": "cmis:objectTypeId",
"propertyValue[0]": "cmis:document",
"propertyId[1]": "cmis:name",
"propertyValue[1]": "Secondary Document",
"propertyId[2]": "cmis:secondaryObjectTypeIds",
"propertyValue[2][0]": "kontextual:secondary",
},
files={"content": ("secondary.txt", b"Secondary content", "text/plain")},
)
assert invalid.status_code == 422
assert invalid.json()["detail"]["details"]["type_id"] == "cmis:folder"
assert created.status_code == 200
assert created.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == ["kontextual:secondary"]
def test_cmis_browser_binding_document_without_content_streams_empty_compatibility_body(cmis_client) -> None:
folder = cmis_client.post(
"/cmis/compat-tck/browser/root",
data={
"cmisaction": "createFolder",
"propertyId[0]": "cmis:objectTypeId",
"propertyValue[0]": "cmis:folder",
"propertyId[1]": "cmis:name",
"propertyValue[1]": "No Content Folder",
},
).json()
document = cmis_client.post(
"/cmis/compat-tck/browser/root",
data={
"cmisaction": "createDocument",
"objectId": folder["properties"]["cmis:objectId"]["value"],
"propertyId[0]": "cmis:objectTypeId",
"propertyValue[0]": "cmis:document",
"propertyId[1]": "cmis:name",
"propertyValue[1]": "No Content Document",
},
).json()
object_id = document["properties"]["cmis:objectId"]["value"]
content = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={"cmisselector": "content", "objectId": object_id},
)
assert document["properties"]["cmis:contentStreamMimeType"]["value"] is None
assert content.status_code == 200
assert content.content == b""
assert content.headers["content-length"] == "0"
def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None:
created = cmis_client.post(
"/cmis/compat-tck/browser/root",
@@ -291,14 +363,77 @@ def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis
},
files={"content": ("multipart.txt", b"Multipart content", "text/plain")},
)
document_path = document.json()["properties"]["cmis:path"]["value"]
document_id = document.json()["properties"]["cmis:objectId"]["value"]
document_path = "/Action Workspace/Multipart Document"
fetched_document_by_path = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={"cmisselector": "object", "path": document_path},
).json()
fetched_document_by_url_path = cmis_client.get(
"/cmis/compat-tck/browser/root/Action%20Workspace/Multipart%20Document",
params={"cmisselector": "object"},
).json()
updated_document_by_alias = cmis_client.post(
"/cmis/compat-tck/browser/root/Action%20Workspace/Multipart%20Document",
data={
"cmisaction": "update",
"propertyId[0]": "cmis:secondaryObjectTypeIds",
"propertyValue[0][0]": "kontextual:secondary",
},
)
destination = cmis_client.post(
"/cmis/compat-tck/browser/root",
data={
"cmisaction": "createFolder",
"propertyId[0]": "cmis:objectTypeId",
"propertyValue[0]": "cmis:folder",
"propertyId[1]": "cmis:name",
"propertyValue[1]": "Destination",
},
)
destination_id = destination.json()["properties"]["cmis:objectId"]["value"]
moved_document = cmis_client.post(
"/cmis/compat-tck/browser/root",
data={
"cmisaction": "move",
"objectId": document_id,
"sourceFolderId": folder_id,
"targetFolderId": destination_id,
},
)
moved_path = "/Destination/Multipart Document"
fetched_old_document_path_after_move = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={"cmisselector": "object", "path": document_path},
)
fetched_moved_document_by_path = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={"cmisselector": "object", "path": moved_path},
).json()
document_parents = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={"cmisselector": "parents", "objectId": document.json()["properties"]["cmis:objectId"]["value"]},
params={"cmisselector": "parents", "objectId": document_id},
).json()
filtered_document = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={
"cmisselector": "object",
"path": moved_path,
"filter": "cmis:objectId,cmis:name",
"includeAllowableActions": False,
"includeACL": False,
},
).json()
filtered_children = cmis_client.get(
"/cmis/compat-tck/browser/root",
params={
"cmisselector": "children",
"objectId": destination_id,
"filter": "cmis:objectId,cmis:name",
"includeAllowableActions": False,
"includeACL": False,
"includePathSegment": False,
},
).json()
fetched_folder_by_path = cmis_client.get(
"/cmis/compat-tck/browser/root",
@@ -334,9 +469,26 @@ def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis
assert document.json()["properties"]["cmis:isLatestVersion"]["value"] is True
assert document.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == []
assert document.json()["allowableActions"]["canGetFolderParent"] is False
assert document.json()["allowableActions"]["canMoveObject"] is True
assert document_path == "/Action Workspace/Multipart Document"
assert fetched_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document"
assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Action Workspace"
assert fetched_document_by_url_path["properties"]["cmis:name"]["value"] == "Multipart Document"
assert updated_document_by_alias.status_code == 200
assert updated_document_by_alias.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == [
"kontextual:secondary"
]
assert destination.status_code == 200
assert moved_document.status_code == 200
assert moved_path == "/Destination/Multipart Document"
assert fetched_old_document_path_after_move.status_code == 404
assert fetched_moved_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document"
assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Destination"
assert document_parents[0]["relativePathSegment"] == "Multipart Document"
assert set(filtered_document["properties"]) == {"cmis:objectId", "cmis:name"}
assert "allowableActions" not in filtered_document
assert "pathSegment" not in filtered_children["objects"][0]
assert set(filtered_children["objects"][0]["object"]["properties"]) == {"cmis:objectId", "cmis:name"}
assert "allowableActions" not in filtered_children["objects"][0]["object"]
assert fetched_folder_by_path["properties"]["cmis:objectId"]["value"] == folder_id
assert deleted_tree.json()["failedToDelete"] == []
assert all(
@@ -368,9 +520,14 @@ def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None:
def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis_client) -> None:
response = cmis_client.post(
"/cmis/governed-authoring/browser/object/cmis:asset:asset-source/properties",
json={"properties": {"cmis:name": "Renamed"}},
json={"properties": {"cmis:objectTypeId": "cmis:folder"}},
)
assert response.status_code == 422
assert response.json()["detail"]["details"]["property"] == "cmis:name"
assert response.json()["detail"]["details"]["supported"] == ["kontextual:metadata:<key>"]
assert response.json()["detail"]["details"]["property"] == "cmis:objectTypeId"
assert response.json()["detail"]["details"]["supported"] == [
"cmis:name",
"cmis:description",
"cmis:secondaryObjectTypeIds",
"kontextual:metadata:<key>",
]

View File

@@ -215,6 +215,24 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru
folder_children = runtime.cmis_children("compat-tck", context, folder_id=folder_object_id)
document_by_path = runtime.cmis_object_by_path("compat-tck", "/TCK Workspace/Workspace Document", context)
document_parents = runtime.cmis_object_parents("compat-tck", document["object_id"], context)
browser_document_parents = runtime.cmis_browser_parents("compat-tck", document["object_id"], context)
filtered_document = runtime.cmis_browser_object(
"compat-tck",
document["object_id"],
context,
property_filter="cmis:objectId,cmis:name",
include_allowable_actions=False,
include_acl=False,
)
filtered_children = runtime.cmis_browser_children(
"compat-tck",
context,
object_id=folder_object_id,
property_filter="cmis:objectId,cmis:name",
include_allowable_actions=False,
include_acl=False,
include_path_segment=False,
)
assert folder["path"] == "/TCK Workspace"
assert folder["properties"]["kontextual:workspaceFolder"] is True
@@ -222,11 +240,17 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru
assert fetched["properties"]["cmis:path"] == "/TCK Workspace"
assert parents["parents"][0]["object_id"] == "cmis-root"
assert document["path"] == "/TCK Workspace/Workspace Document"
assert document["properties"]["cmis:path"] == "/TCK Workspace/Workspace Document"
assert "cmis:path" not in document["properties"]
assert document_by_path["object_id"] == document["object_id"]
assert document_parents["count"] == 1
assert document_parents["parents"][0]["properties"]["cmis:path"] == "/TCK Workspace"
assert browser_document_parents[0]["relativePathSegment"] == "Workspace Document"
assert document["object_id"] in {item["object_id"] for item in folder_children["objects"]}
assert set(filtered_document["properties"]) == {"cmis:objectId", "cmis:name"}
assert "allowableActions" not in filtered_document
assert "pathSegment" not in filtered_children["objects"][0]
assert set(filtered_children["objects"][0]["object"]["properties"]) == {"cmis:objectId", "cmis:name"}
assert "allowableActions" not in filtered_children["objects"][0]["object"]
with pytest.raises(Exception) as exc_info:
runtime.cmis_delete_object("compat-tck", folder_object_id, {}, context)
@@ -242,6 +266,31 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru
assert "CMIS folder not found" in str(exc_info.value)
def test_runtime_cmis_workspace_folder_rename_keeps_object_id_stable(cmis_runtime) -> None:
runtime, context = cmis_runtime
folder = runtime.cmis_create_folder(
"compat-tck",
{"name": "Rename Source", "properties": {"cmis:objectTypeId": "cmis:folder"}},
context,
)
renamed = runtime.cmis_update_properties(
"compat-tck",
folder["object_id"],
{"properties": {"cmis:name": "Rename Target"}},
context,
)
fetched_by_old_id = runtime.cmis_object("compat-tck", folder["object_id"], context)
fetched_by_new_path = runtime.cmis_object_by_path("compat-tck", "/Rename Target", context)
deleted = runtime.cmis_delete_object("compat-tck", folder["object_id"], {}, context)
assert renamed["object_id"] == folder["object_id"]
assert renamed["path"] == "/Rename Target"
assert fetched_by_old_id["properties"]["cmis:name"] == "Rename Target"
assert fetched_by_new_path["object_id"] == folder["object_id"]
assert deleted["deleted"] is True
def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime) -> None:
runtime, context = cmis_runtime
@@ -249,7 +298,7 @@ def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime
runtime.cmis_update_properties(
"governed-authoring",
"cmis:asset:asset-runtime-source",
{"properties": {"cmis:name": "Renamed"}},
{"properties": {"cmis:objectTypeId": "cmis:folder"}},
context,
)

View File

@@ -127,6 +127,75 @@ Current external frontier:
These are follow-up maturity items rather than the original folder-creatable
blocker.
## Implementation Evidence - 2026-05-08T15:33:16Z
Evidence file:
- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md`
Implemented in this pass:
- Browser Binding action aliases and path-addressed routes:
`cmisaction=update`, `cmisaction=move`, and `/browser/root/{path}`.
- MIME normalization and explicit content stream `Content-Type` headers.
- Metadata-backed standard property support for `cmis:name`,
`cmis:description`, and `cmis:secondaryObjectTypeIds`.
- Create-time secondary type id projection and invalid document type rejection.
- Removal of non-standard document `cmis:path` while preserving folder paths.
Latest verification:
- Internal: `.venv/bin/python -m pytest tests/cmis -q` -> `48 passed`.
- OpenCMIS: `run-20260508T153316Z` in
`/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z`.
- Maturity score: `19.05`; coverage remains `2/9` groups.
- `repository-type` is now partial/warning; `object-content` remains
infrastructure-blocked by concrete CRUD/content edge cases.
Current external frontier:
- `getObjectByPath` path-segment failures in child checks.
- No-content document content-stream semantics.
- Operation-context/property filter trimming, especially folder `cmis:path`.
- `bulkUpdate`, `deleteContent`, and change-token unsupported-action handling.
## Implementation Evidence - 2026-05-08T16:43:34Z
Evidence file:
- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md`
Implemented in this pass:
- Correct `relativePathSegment` behavior for document parents.
- Browser Binding operation-context trimming for property filters, allowable
actions, ACLs, and path segments.
- Total-count `numItems` semantics for Browser Binding children.
- Stable adapter-managed folder object IDs across folder rename/update.
- Nullable no-content document stream properties and empty compatibility streams.
- Range-aware content responses with sliced bodies, `206`, and `Content-Range`.
- `setContent` and `deleteContent` Browser Binding action aliases.
Latest verification:
- Internal focused CMIS tests: `20 passed`.
- Full suite: `160 passed, 14 skipped`.
- OpenCMIS: `run-20260508T164334Z` in
`/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z`.
- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`.
- `object-content`: `12 info`, `5 skipped`, `3 warning`, `3 fail`,
`3 infrastructure_error`.
Current external frontier:
- CMIS-specific exception mapping for invalid type operations.
- `bulkUpdateProperties` remains unsupported.
- `deleteContentStream` needs stronger representation-removal/tombstone
semantics.
- Change-token conflict behavior is not implemented.
- `createDocumentFromSource`/copy remains unsupported.
- Offset-zero range requests are still marked partial.
## D14.1 - Define CMIS maturity boundary and TCK profile semantics
```task
@@ -187,7 +256,7 @@ Acceptance:
```task
id: KONT-WP-0014-T004
status: in_progress
status: done
priority: high
state_hub_task_id: "f9323c25-4d81-42cd-b7e6-e40d7e0487cd"
```
@@ -208,7 +277,7 @@ Acceptance:
```task
id: KONT-WP-0014-T005
status: todo
status: in_progress
priority: medium
state_hub_task_id: "5feb6db8-24eb-4c20-8c3e-d530f396ef6a"
```
@@ -223,6 +292,13 @@ Acceptance:
naturally through blob services or explicitly advertised as unsupported.
- Blob deduplication and digest verification remain intact.
Progress:
- Done for normal reads, no-content compatibility streams, partial body slicing,
`Content-Length`, `Content-Type`, `ETag`, and `Content-Range`.
- Remaining: clean `deleteContentStream` semantics and offset-zero range
classification.
## D14.6 - Add natural navigation and query depth
```task
@@ -242,11 +318,18 @@ Acceptance:
indexed/available metadata fields, or returns precise unsupported diagnostics.
- Capability flags are updated only for behavior that is actually supported.
Progress:
- Done for `getObjectByPath`, `getFolderParent`, parent path segments, and
folder rename path stability.
- Remaining: query predicate/order depth and any deliberate descendants/tree
expansion.
## D14.7 - Polish read-side relationships, ACL discovery, and change tokens
```task
id: KONT-WP-0014-T007
status: todo
status: in_progress
priority: medium
state_hub_task_id: "60f7b222-6eea-4add-822d-3439d568d4f6"
```
@@ -261,6 +344,11 @@ Acceptance:
- ACL mutation, policy mutation, PWC/versioning, and type mutability remain
unsupported unless a later task explicitly changes scope.
Progress:
- Started by isolating OpenCMIS change-token failures as the main T007 maturity
gap. Relationship and ACL discovery were not expanded in this pass.
## D14.8 - Expand OpenCMIS assessment and update maturity scorecard
```task